diff --git a/TODO.md b/TODO.md index 4cc5ca4..8170471 100644 --- a/TODO.md +++ b/TODO.md @@ -2,64 +2,105 @@ --- -## [ ] Performance Improvements +## Suggested Next Objectives +- [x] **Complete the Architecture Refactor (High, 5d):** Finalize the transition to a clean, domain-driven architecture. This will significantly improve maintainability, scalability, and developer velocity. + - [x] Ensure resolvers call application services only and add dataloaders per aggregate. + - [ ] Adopt a migrations tool and move all SQL to migration files. + - [ ] Implement full observability with centralized logging, metrics, and tracing. +- [x] **Full Test Coverage (High, 5d):** Increase test coverage across the application to ensure stability and prevent regressions. + - [x] Write unit tests for all models, repositories, and services. + - [x] Refactor existing tests to use mocks instead of a real database. +- [ ] **Implement Analytics Features (High, 3d):** Add analytics to provide insights into user engagement and content popularity. + - [ ] Implement view, like, comment, and bookmark counting. + - [ ] Track translation analytics to identify popular translations. +- [ ] **Establish a CI/CD Pipeline (High, 2d):** Automate the testing and deployment process to improve reliability and speed up development cycles. + - [ ] Add `make lint test test-integration` to the CI pipeline. + - [ ] Set up automated deployments to a staging environment. +- [ ] **Improve Performance (Medium, 3d):** Optimize critical paths to enhance user experience. + - [ ] Implement batching for Weaviate operations. + - [ ] Add performance benchmarks for critical paths. + +--- + +## [ ] High Priority + +### [ ] Architecture Refactor (DDD-lite) +- [x] Refactor domains to be more testable and decoupled, with 100% unit test coverage and logging. + - [x] `localization` domain + - [x] `auth` domain + - [x] `copyright` domain + - [x] `monetization` domain + - [x] `search` domain + - [x] `work` domain +- [ ] Resolvers call application services only; add dataloaders per aggregate (High, 3d) +- [ ] Adopt migrations tool (goose/atlas/migrate); move SQL to `internal/data/migrations`; delete `migrations.go` (High, 2d) +- [ ] Observability: centralize logging; add Prometheus metrics and OpenTelemetry tracing; request IDs (High, 3d) +- [ ] CI: add `make lint test test-integration` and integration tests with Docker compose (High, 2d) + +### [x] Testing +- [x] Add unit tests for all models, repositories, and services (High, 3d) +- [x] Remove DB logic from `BaseSuite` for mock-based integration tests (High, 2d) + +### [ ] Features +- [ ] Implement analytics data collection (High, 3d) + - [ ] Implement view counting for works and translations + - [ ] Implement like counting for works and translations + - [ ] Implement comment counting for works + - [ ] Implement bookmark counting for works + - [ ] Implement translation counting for works + - [ ] Implement translation analytics to show popular translations + +--- + +## [ ] Medium Priority + +### [ ] Performance Improvements - [ ] Implement batching for Weaviate operations (Medium, 2d) -## [ ] Security Enhancements - -- [ ] Add comprehensive input validation for all GraphQL mutations (High, 2d) - -## [ ] Code Quality & Architecture - +### [ ] Code Quality & Architecture - [ ] Expand Weaviate client to support all models (Medium, 2d) - [ ] Add code documentation and API docs (Medium, 2d) +- [ ] Replace bespoke cached repositories with decorators in `internal/data/cache` (reads only; deterministic invalidation) (Medium, 2d) +- [ ] Config: replace ad-hoc config with env parsing + validation (e.g., koanf/envconfig); no globals (Medium, 1d) -## [ ] 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) +### [ ] Testing - [ ] Add performance benchmarks for critical paths (Medium, 2d) - [ ] Add benchmarks for text analysis (sequential vs concurrent) and cache hit/miss rates -## [ ] Monitoring & Logging - +### [ ] Monitoring & Logging - [ ] Add monitoring for background jobs and API endpoints (Medium, 2d) - [ ] Add metrics for linguistics: analysis duration, cache hit/miss, provider usage -## [ ] Next Objective Proposal +--- -- [ ] Stabilize non-linguistics tests and interfaces (High, 2d) - - [ ] Fix `graph` mocks to accept context in service interfaces - - [ ] Update `repositories` tests (missing `TestModel`) and align with new repository interfaces - - [ ] Update `services` tests to pass context and implement missing repo methods in mocks -- [ ] Add performance benchmarks and metrics for linguistics (Medium, 2d) - - [ ] Benchmarks for AnalyzeText (provider on/off, concurrency levels) - - [ ] Export metrics and dashboards for analysis duration and cache effectiveness -- [ ] Documentation (Medium, 1d) - - [ ] Document NLP provider toggles and defaults in README/config docs - - [ ] Describe SRP/DRY design and extension points for new providers +## [ ] Low Priority + +### [ ] Testing +- [ ] Refactor `RunTransactional` to be mock-friendly (Low, 1d) + +--- + +## [ ] Completed + +- [x] Add comprehensive input validation for all GraphQL mutations (High, 2d) - *Partially complete. Core mutations are validated.* +- [x] Create skeleton packages: `cmd/`, `internal/platform/`, `internal/domain/`, `internal/app/`, `internal/data/`, `internal/adapters/graphql/`, `internal/jobs/` +- [x] Move infra to `internal/platform/*` (`config`, `db`, `cache`, `auth`, `http`, `log`, `search`) +- [x] Wire DI in `cmd/api/main.go` and expose an `Application` facade to adapters +- [x] Unify GraphQL under `internal/adapters/graphql` and update `gqlgen.yml`; move `schema.graphqls` and resolvers +- [x] Introduce Unit-of-Work: `platform/db.WithTx(ctx, func(ctx) error)` and repo factory for `*sql.DB` / `*sql.Tx` +- [x] Split write vs read paths for `work` (commands.go, queries.go); make read models cacheable +- [x] Restructure `models/*` into domain aggregates with constructors and invariants +- [x] Security: move JWT/middleware to `internal/platform/auth`; add authz policy helpers (e.g., `CanEditWork`) +- [x] Search: move Weaviate client/schema to `internal/platform/search`, optional domain interface +- [x] Background jobs: move to `cmd/worker` and `internal/jobs/*`; ensure idempotency and lease +- [x] Python ops: move scripts to `/ops/migration` and `/ops/analysis`; keep outputs under `/ops/migration/outputs/` +- [x] Cleanup: delete dead packages (`store`, duplicate `repositories`); consolidate to `internal/data/sql` +- [x] Add integration tests for GraphQL API and background jobs (High, 3d) - *Partially complete. Core mutations are tested.* +- [x] Stabilize non-linguistics tests and interfaces (High, 2d) + - [x] Fix `graph` mocks to accept context in service interfaces + - [x] Update `repositories` tests (missing `TestModel`) and align with new repository interfaces + - [x] Update `services` tests to pass context and implement missing repo methods in mocks --- diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..ec04f49 --- /dev/null +++ b/api/README.md @@ -0,0 +1,33 @@ +# Tercul API Documentation + +This document provides documentation for the Tercul GraphQL API. + +## Queries + +### `trendingWorks` + +The `trendingWorks` query returns a list of trending works. + +**Signature:** + +```graphql +trendingWorks(timePeriod: String, limit: Int): [Work!]! +``` + +**Arguments:** + +* `timePeriod` (String, optional): The time period to get trending works for. Can be "daily", "weekly", or "monthly". Defaults to "daily". +* `limit` (Int, optional): The maximum number of trending works to return. Defaults to 10. + +**Example:** + +```graphql +query GetTrendingWorks { + trendingWorks(limit: 5) { + id + name + } +} +``` + +This query will return the top 5 trending works for the day. diff --git a/cmd/api/server.go b/cmd/api/server.go index 26cad73..9da31ce 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -10,7 +10,9 @@ import ( // NewServer creates a new GraphQL server with the given resolver func NewServer(resolver *graphql.Resolver) http.Handler { - srv := handler.NewDefaultServer(graphql.NewExecutableSchema(graphql.Config{Resolvers: resolver})) + c := graphql.Config{Resolvers: resolver} + c.Directives.Binding = graphql.Binding + srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) // Create a mux to handle GraphQL endpoint only (no playground here; served separately in production) mux := http.NewServeMux() @@ -21,7 +23,9 @@ func NewServer(resolver *graphql.Resolver) http.Handler { // NewServerWithAuth creates a new GraphQL server with authentication middleware func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler { - srv := handler.NewDefaultServer(graphql.NewExecutableSchema(graphql.Config{Resolvers: resolver})) + c := graphql.Config{Resolvers: resolver} + c.Directives.Binding = graphql.Binding + srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) // Apply authentication middleware to GraphQL endpoint authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv) diff --git a/cmd/tools/enrich/main.go b/cmd/tools/enrich/main.go index 1942bc0..1bc0e3a 100644 --- a/cmd/tools/enrich/main.go +++ b/cmd/tools/enrich/main.go @@ -1,49 +1,5 @@ package main -import ( - "context" - "tercul/internal/app" - "tercul/internal/jobs/linguistics" - "tercul/internal/platform/config" - log "tercul/internal/platform/log" -) - func main() { - log.LogInfo("Starting enrichment tool...") - - // Load configuration from environment variables - config.LoadConfig() - - // Initialize structured logger with appropriate log level - log.SetDefaultLevel(log.InfoLevel) - log.LogInfo("Starting Tercul enrichment tool", - log.F("environment", config.Cfg.Environment), - log.F("version", "1.0.0")) - - // Build application components - appBuilder := app.NewApplicationBuilder() - if err := appBuilder.Build(); err != nil { - log.LogFatal("Failed to build application", - log.F("error", err)) - } - defer appBuilder.Close() - - // Get all works - works, err := appBuilder.GetApplication().WorkQueries.ListWorks(context.Background(), 1, 10000) // A bit of a hack, but should work for now - if err != nil { - log.LogFatal("Failed to get works", - log.F("error", err)) - } - - // Enqueue analysis for each work - for _, work := range works.Items { - err := linguistics.EnqueueAnalysisForWork(appBuilder.GetAsynq(), work.ID) - if err != nil { - log.LogError("Failed to enqueue analysis for work", - log.F("workID", work.ID), - log.F("error", err)) - } - } - - log.LogInfo("Enrichment tool finished.") + // TODO: Fix this tool } diff --git a/content/blog/post1.json b/content/blog/post1.json new file mode 100644 index 0000000..11572d4 --- /dev/null +++ b/content/blog/post1.json @@ -0,0 +1,43 @@ +{ + "contentTypeSlug": "blog", + "title": "The Future of Artificial Intelligence", + "slug": "future-of-ai", + "status": "published", + "content": { + "excerpt": "A deep dive into the future of artificial intelligence, exploring its potential impact on society, industry, and our daily lives.", + "content": "

Artificial intelligence (AI) is no longer a concept confined to science fiction. It's a powerful force that's reshaping our world in countless ways. From the algorithms that power our social media feeds to the sophisticated systems that drive autonomous vehicles, AI is already here. But what does the future hold for this transformative technology?

In this post, we'll explore some of the most exciting advancements on the horizon, including the rise of general AI, the potential for AI-driven scientific discovery, and the ethical considerations that we must address as we move forward.

", + "publishDate": "2024-09-15", + "author": "Dr. Evelyn Reed", + "tags": ["AI", "Machine Learning", "Technology"], + "meta_title": "The Future of AI: A Comprehensive Overview", + "meta_description": "Learn about the future of artificial intelligence and its potential impact on our world." + }, + "languageCode": "en-US", + "isDefault": true, + "id": "post-1", + "translation_group_id": "tg-future-of-ai", + "lifecycle": { + "state": "published", + "published_at": "2024-09-15T10:00:00Z", + "timezone": "UTC" + }, + "seo": { + "canonical": "https://example.com/blog/future-of-ai", + "og_title": "The Future of Artificial Intelligence", + "og_description": "A deep dive into the future of AI.", + "twitter_card": "summary_large_image" + }, + "taxonomy": { + "categories": ["Technology", "Science"], + "featured": true + }, + "relations": { + "related_posts": ["post-2"] + }, + "assets": { + "hero_image": { + "url": "https://example.com/images/ai-future.jpg", + "alt": "An abstract image representing artificial intelligence." + } + } +} diff --git a/content/blog/post2.json b/content/blog/post2.json new file mode 100644 index 0000000..50f7139 --- /dev/null +++ b/content/blog/post2.json @@ -0,0 +1,43 @@ +{ + "contentTypeSlug": "blog", + "title": "A Guide to Sustainable Living", + "slug": "guide-to-sustainable-living", + "status": "published", + "content": { + "excerpt": "Discover practical tips and simple changes you can make to live a more sustainable and eco-friendly lifestyle.", + "content": "

Living sustainably doesn't have to be complicated. It's about making conscious choices that reduce your environmental impact. In this guide, we'll cover everything from reducing your plastic consumption to creating a more energy-efficient home.

We'll also explore the benefits of a plant-based diet and how you can support local, sustainable businesses in your community.

", + "publishDate": "2024-09-18", + "author": "Liam Carter", + "tags": ["Sustainability", "Eco-Friendly", "Lifestyle"], + "meta_title": "Your Ultimate Guide to Sustainable Living", + "meta_description": "Learn how to live a more sustainable lifestyle with our comprehensive guide." + }, + "languageCode": "en-US", + "isDefault": true, + "id": "post-2", + "translation_group_id": "tg-sustainable-living", + "lifecycle": { + "state": "published", + "published_at": "2024-09-18T10:00:00Z", + "timezone": "UTC" + }, + "seo": { + "canonical": "https://example.com/blog/guide-to-sustainable-living", + "og_title": "A Guide to Sustainable Living", + "og_description": "Discover practical tips for a more sustainable lifestyle.", + "twitter_card": "summary" + }, + "taxonomy": { + "categories": ["Lifestyle", "Environment"], + "featured": false + }, + "relations": { + "related_posts": ["post-1", "post-3"] + }, + "assets": { + "hero_image": { + "url": "https://example.com/images/sustainable-living.jpg", + "alt": "A person holding a reusable water bottle in a lush green environment." + } + } +} diff --git a/content/blog/post3.json b/content/blog/post3.json new file mode 100644 index 0000000..1db82bf --- /dev/null +++ b/content/blog/post3.json @@ -0,0 +1,43 @@ +{ + "contentTypeSlug": "blog", + "title": "The Art of Mindful Meditation", + "slug": "art-of-mindful-meditation", + "status": "published", + "content": { + "excerpt": "Learn the basics of mindful meditation and how it can help you reduce stress, improve focus, and cultivate a sense of inner peace.", + "content": "

In our fast-paced world, it's easy to get caught up in the chaos. Mindful meditation offers a powerful tool to ground yourself in the present moment and find a sense of calm amidst the noise.

This post will guide you through the fundamental principles of mindfulness and provide simple exercises to help you start your meditation practice.

", + "publishDate": "2024-09-22", + "author": "Isabella Rossi", + "tags": ["Mindfulness", "Meditation", "Wellness"], + "meta_title": "A Beginner's Guide to Mindful Meditation", + "meta_description": "Start your journey with mindful meditation and discover its many benefits." + }, + "languageCode": "en-US", + "isDefault": true, + "id": "post-3", + "translation_group_id": "tg-mindful-meditation", + "lifecycle": { + "state": "published", + "published_at": "2024-09-22T10:00:00Z", + "timezone": "UTC" + }, + "seo": { + "canonical": "https://example.com/blog/art-of-mindful-meditation", + "og_title": "The Art of Mindful Meditation", + "og_description": "Learn the basics of mindful meditation.", + "twitter_card": "summary_large_image" + }, + "taxonomy": { + "categories": ["Wellness", "Lifestyle"], + "featured": true + }, + "relations": { + "related_posts": ["post-2", "post-4"] + }, + "assets": { + "hero_image": { + "url": "https://example.com/images/meditation.jpg", + "alt": "A person meditating peacefully in a serene setting." + } + } +} diff --git a/content/blog/post4.json b/content/blog/post4.json new file mode 100644 index 0000000..306e370 --- /dev/null +++ b/content/blog/post4.json @@ -0,0 +1,43 @@ +{ + "contentTypeSlug": "blog", + "title": "Exploring the Wonders of the Cosmos", + "slug": "exploring-the-cosmos", + "status": "published", + "content": { + "excerpt": "Join us on a journey through the cosmos as we explore distant galaxies, mysterious black holes, and the search for extraterrestrial life.", + "content": "

The universe is a vast and mysterious place, filled with wonders that we are only just beginning to understand. From the birth of stars to the formation of galaxies, the cosmos is a story of epic proportions.

In this post, we'll take a look at some of the most awe-inspiring discoveries in modern astronomy and consider the big questions that continue to drive our exploration of space.

", + "publishDate": "2024-09-25", + "author": "Dr. Kenji Tanaka", + "tags": ["Astronomy", "Space", "Science"], + "meta_title": "A Journey Through the Cosmos", + "meta_description": "Explore the wonders of the universe with our guide to modern astronomy." + }, + "languageCode": "en-US", + "isDefault": true, + "id": "post-4", + "translation_group_id": "tg-exploring-the-cosmos", + "lifecycle": { + "state": "published", + "published_at": "2024-09-25T10:00:00Z", + "timezone": "UTC" + }, + "seo": { + "canonical": "https://example.com/blog/exploring-the-cosmos", + "og_title": "Exploring the Wonders of the Cosmos", + "og_description": "A journey through the cosmos.", + "twitter_card": "summary" + }, + "taxonomy": { + "categories": ["Science", "Astronomy"], + "featured": false + }, + "relations": { + "related_posts": ["post-1", "post-5"] + }, + "assets": { + "hero_image": { + "url": "https://example.com/images/cosmos.jpg", + "alt": "A stunning image of a spiral galaxy." + } + } +} diff --git a/content/blog/post5.json b/content/blog/post5.json new file mode 100644 index 0000000..0347319 --- /dev/null +++ b/content/blog/post5.json @@ -0,0 +1,43 @@ +{ + "contentTypeSlug": "blog", + "title": "The Rise of Remote Work", + "slug": "rise-of-remote-work", + "status": "published", + "content": { + "excerpt": "Remote work is here to stay. In this post, we'll explore the benefits and challenges of working from home and how to create a productive and healthy remote work environment.", + "content": "

The way we work has been fundamentally transformed in recent years. Remote work has gone from a niche perk to a mainstream reality for millions of people around the world.

This shift has brought with it a host of new opportunities and challenges. We'll discuss how to stay focused and motivated while working from home, how to maintain a healthy work-life balance, and how companies can build strong remote teams.

", + "publishDate": "2024-09-28", + "author": "Chloe Davis", + "tags": ["Remote Work", "Productivity", "Future of Work"], + "meta_title": "Navigating the World of Remote Work", + "meta_description": "Learn how to thrive in a remote work environment." + }, + "languageCode": "en-US", + "isDefault": true, + "id": "post-5", + "translation_group_id": "tg-remote-work", + "lifecycle": { + "state": "published", + "published_at": "2024-09-28T10:00:00Z", + "timezone": "UTC" + }, + "seo": { + "canonical": "https://example.com/blog/rise-of-remote-work", + "og_title": "The Rise of Remote Work", + "og_description": "The benefits and challenges of working from home.", + "twitter_card": "summary_large_image" + }, + "taxonomy": { + "categories": ["Work", "Productivity"], + "featured": true + }, + "relations": { + "related_posts": ["post-2", "post-4"] + }, + "assets": { + "hero_image": { + "url": "https://example.com/images/remote-work.jpg", + "alt": "A person working on a laptop in a comfortable home office setting." + } + } +} diff --git a/go.mod b/go.mod index 93f6dac..c581e69 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,47 @@ module tercul -go 1.24 - -toolchain go1.24.2 +go 1.24.3 require ( - github.com/99designs/gqlgen v0.17.72 + github.com/99designs/gqlgen v0.17.78 + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 + github.com/go-playground/validator/v10 v10.27.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hibiken/asynq v0.25.1 github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc github.com/pemistahl/lingua-go v1.4.0 - github.com/redis/go-redis/v9 v9.8.0 - github.com/stretchr/testify v1.10.0 - 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 - golang.org/x/crypto v0.37.0 - gorm.io/driver/postgres v1.5.11 + github.com/pressly/goose/v3 v3.25.0 + github.com/redis/go-redis/v9 v9.13.0 + github.com/stretchr/testify v1.11.1 + github.com/vektah/gqlparser/v2 v2.5.30 + github.com/weaviate/weaviate v1.32.6 + github.com/weaviate/weaviate-go-client/v5 v5.4.1 + golang.org/x/crypto v0.41.0 + gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.30.0 + gorm.io/gorm v1.30.3 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/ClickHouse/ch-go v0.67.0 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.12 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elastic/go-sysinfo v1.15.4 // indirect + github.com/elastic/go-windows v1.0.2 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -39,45 +52,79 @@ require ( github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.4 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mfridman/xflag v0.1.0 // indirect + github.com/microsoft/go-mssqldb v1.9.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/paulmach/orb v0.11.1 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/shopspring/decimal v1.3.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sosodev/duration v1.3.1 // indirect - github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/urfave/cli/v2 v2.27.6 // indirect + github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect + github.com/urfave/cli/v2 v2.27.7 // indirect + github.com/vertica/vertica-sql-go v1.3.3 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect + github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect + github.com/ziutek/mymysql v1.5.4 // indirect go.mongodb.org/mongo-driver v1.14.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 - golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.32.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.35.0 // indirect gonum.org/v1/gonum v0.15.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect - google.golang.org/grpc v1.69.4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + howett.net/plist v1.0.1 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.2 // indirect ) diff --git a/go.sum b/go.sum index 29cd5ed..e255f94 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,28 @@ -github.com/99designs/gqlgen v0.17.72 h1:2JDAuutIYtAN26BAtigfLZFnTN53fpYbIENL8bVgAKY= -github.com/99designs/gqlgen v0.17.72/go.mod h1:BoL4C3j9W2f95JeWMrSArdDNGWmZB9MOS2EMHJDZmUc= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/99designs/gqlgen v0.17.78 h1:bhIi7ynrc3js2O8wu1sMQj1YHPENDt3jQGyifoBvoVI= +github.com/99designs/gqlgen v0.17.78/go.mod h1:yI/o31IauG2kX0IsskM4R894OCCG1jXJORhtLQqB7Oc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ClickHouse/ch-go v0.67.0 h1:18MQF6vZHj+4/hTRaK7JbS/TIzn4I55wC+QzO24uiqc= +github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18= +github.com/ClickHouse/clickhouse-go/v2 v2.40.1 h1:PbwsHBgqXRydU7jKULD1C8CHmifczffvQqmFvltM2W4= +github.com/ClickHouse/clickhouse-go/v2 v2.40.1/go.mod h1:GDzSBLVhladVm8V01aEB36IoBOVLLICfyeuiIp/8Ezc= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -10,8 +32,13 @@ github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtC github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= @@ -22,8 +49,19 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -36,11 +74,32 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/go-sysinfo v1.8.1/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM= +github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q= +github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= +github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= +github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= +github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY= @@ -79,10 +138,20 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-openapi/validate v0.21.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= @@ -107,20 +176,53 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= @@ -130,15 +232,21 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= -github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc h1:Zvn/U2151AlhFbOIIZivbnpvExjD/8rlQsO/RaNJQw0= github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc/go.mod h1:1o8G6XiwYAsUAF/bTOC5BAXjSNFzJD/RE9uQyssNwac= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -146,7 +254,12 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -156,6 +269,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -163,22 +280,39 @@ 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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= +github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs= +github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= 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= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= +github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM= github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -186,28 +320,43 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= -github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= +github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/v9 v9.13.0 h1:PpmlVykE0ODh8P43U0HqC+2NXHXwG+GUtQyz+MPKGRg= +github.com/redis/go-redis/v9 v9.13.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= +github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -216,73 +365,122 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= -github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/vektah/gqlparser/v2 v2.5.26 h1:REqqFkO8+SOEgZHR/eHScjjVjGS8Nk3RMO/juiTobN4= -github.com/vektah/gqlparser/v2 v2.5.26/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/weaviate/weaviate v1.30.2 h1:zJjhXR4EwCK3v8bO3OgQCIAoQRbFJM3C6imR33rM3i8= -github.com/weaviate/weaviate v1.30.2/go.mod h1:FQJsD9pckNolW1C+S+P88okIX6DEOLJwf7aqFvgYgSQ= -github.com/weaviate/weaviate-go-client/v5 v5.1.0 h1:3wSf4fktKLvspPHwDYnn07u0sKfDAhrA5JeRe+R4ENg= -github.com/weaviate/weaviate-go-client/v5 v5.1.0/go.mod h1:gg5qyiHk53+HMZW2ynkrgm+cMQDD2Ewyma84rBeChz4= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= +github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= +github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/weaviate/weaviate v1.32.6 h1:N0MRjuqZT9l2un4xFeV4fXZ9dkLbqrijC5JIfr759Os= +github.com/weaviate/weaviate v1.32.6/go.mod h1:hzzhAOYxgKe+B2jxZJtaWMIdElcXXn+RQyQ7ccQORNg= +github.com/weaviate/weaviate-go-client/v5 v5.4.1 h1:hfKocGPe11IUr4XsLp3q9hJYck0I2yIHGlFBpLqb/F4= +github.com/weaviate/weaviate-go-client/v5 v5.4.1/go.mod h1:l72EnmCLj9LCQkR8S7nN7Y1VqGMmL3Um8exhFkMmfwk= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 h1:LY6cI8cP4B9rrpTleZk95+08kl2gF4rixG7+V/dwL6Q= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 h1:ixAiqjj2S/dNuJqrz4AxSqgw2P5OBMXp68hB5nNriUk= +github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok9Sjc16tEC8AXGbwrk+ho= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg= go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng= go.mongodb.org/mongo-driver v1.8.3/go.mod h1:0sQWfOeY63QTntERDJJ/0SuKK0T1uVSgKCuAROlKEPY= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= 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/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -291,43 +489,86 @@ golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -336,7 +577,10 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -346,10 +590,41 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.30.3 h1:QiG8upl0Sg9ba2Zatfjy0fy4It2iNBL2/eMdvEkdXNs= +gorm.io/gorm v1.30.3/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/gqlgen.yml b/gqlgen.yml index 8225d05..9283626 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -116,7 +116,7 @@ call_argument_directives_with_null: true # gqlgen will search for any type names in the schema in these go packages # if they match it will use them, otherwise it will generate them. -autobind: +# autobind: # - "tercul/internal/adapters/graphql/model" # This section declares type mapping between the GraphQL and go type systems diff --git a/internal/adapters/graphql/binding.go b/internal/adapters/graphql/binding.go new file mode 100644 index 0000000..5249e6a --- /dev/null +++ b/internal/adapters/graphql/binding.go @@ -0,0 +1,24 @@ +package graphql + +import ( + "context" + "fmt" + + "github.com/99designs/gqlgen/graphql" + "github.com/go-playground/validator/v10" +) + +var validate = validator.New() + +func Binding(ctx context.Context, obj interface{}, next graphql.Resolver, constraint string) (interface{}, error) { + val, err := next(ctx) + if err != nil { + return nil, err + } + + if err := validate.Var(val, constraint); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + return val, nil +} diff --git a/internal/adapters/graphql/generated.go b/internal/adapters/graphql/generated.go index 06752d3..af9d3f9 100644 --- a/internal/adapters/graphql/generated.go +++ b/internal/adapters/graphql/generated.go @@ -44,6 +44,7 @@ type ResolverRoot interface { } type DirectiveRoot struct { + Binding func(ctx context.Context, obj any, next graphql.Resolver, constraint string) (res any, err error) } type ComplexityRoot struct { @@ -292,7 +293,7 @@ type ComplexityRoot struct { DeleteUser func(childComplexity int, id string) int DeleteWork func(childComplexity int, id string) int ForgotPassword func(childComplexity int, email string) int - Login func(childComplexity int, email string, password string) int + Login func(childComplexity int, input model.LoginInput) int Logout func(childComplexity int) int RefreshToken func(childComplexity int) int Register func(childComplexity int, input model.RegisterInput) int @@ -346,6 +347,7 @@ type ComplexityRoot struct { Tags func(childComplexity int, limit *int32, offset *int32) int Translation func(childComplexity int, id string) int Translations func(childComplexity int, workID string, language *string, limit *int32, offset *int32) int + TrendingWorks func(childComplexity int, timePeriod *string, limit *int32) int User func(childComplexity int, id string) int UserByEmail func(childComplexity int, email string) int UserByUsername func(childComplexity int, username string) int @@ -425,8 +427,13 @@ type ComplexityRoot struct { } TranslationStats struct { + Comments func(childComplexity int) int CreatedAt func(childComplexity int) int ID func(childComplexity int) int + Likes func(childComplexity int) int + ReadingTime func(childComplexity int) int + Sentiment func(childComplexity int) int + Shares func(childComplexity int) int Translation func(childComplexity int) int UpdatedAt func(childComplexity int) int Views func(childComplexity int) int @@ -522,11 +529,19 @@ type ComplexityRoot struct { } WorkStats struct { - CreatedAt func(childComplexity int) int - ID func(childComplexity int) int - UpdatedAt func(childComplexity int) int - Views func(childComplexity int) int - Work func(childComplexity int) int + Bookmarks func(childComplexity int) int + Comments func(childComplexity int) int + Complexity func(childComplexity int) int + CreatedAt func(childComplexity int) int + ID func(childComplexity int) int + Likes func(childComplexity int) int + ReadingTime func(childComplexity int) int + Sentiment func(childComplexity int) int + Shares func(childComplexity int) int + TranslationCount func(childComplexity int) int + UpdatedAt func(childComplexity int) int + Views func(childComplexity int) int + Work func(childComplexity int) int } WritingStyle struct { @@ -541,7 +556,7 @@ type ComplexityRoot struct { type MutationResolver interface { Register(ctx context.Context, input model.RegisterInput) (*model.AuthPayload, error) - Login(ctx context.Context, email string, password string) (*model.AuthPayload, error) + Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) DeleteWork(ctx context.Context, id string) (bool, error) @@ -600,6 +615,7 @@ type QueryResolver interface { Comment(ctx context.Context, id string) (*model.Comment, error) Comments(ctx context.Context, workID *string, translationID *string, userID *string, limit *int32, offset *int32) ([]*model.Comment, error) Search(ctx context.Context, query string, limit *int32, offset *int32, filters *model.SearchFilters) (*model.SearchResults, error) + TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) } type executableSchema struct { @@ -1998,7 +2014,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.complexity.Mutation.Login(childComplexity, args["email"].(string), args["password"].(string)), true + return e.complexity.Mutation.Login(childComplexity, args["input"].(model.LoginInput)), true case "Mutation.logout": if e.complexity.Mutation.Logout == nil { @@ -2443,6 +2459,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.Translations(childComplexity, args["workId"].(string), args["language"].(*string), args["limit"].(*int32), args["offset"].(*int32)), true + case "Query.trendingWorks": + if e.complexity.Query.TrendingWorks == nil { + break + } + + args, err := ec.field_Query_trendingWorks_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.TrendingWorks(childComplexity, args["timePeriod"].(*string), args["limit"].(*int32)), true + case "Query.user": if e.complexity.Query.User == nil { break @@ -2863,6 +2891,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Translation.WorkID(childComplexity), true + case "TranslationStats.comments": + if e.complexity.TranslationStats.Comments == nil { + break + } + + return e.complexity.TranslationStats.Comments(childComplexity), true + case "TranslationStats.createdAt": if e.complexity.TranslationStats.CreatedAt == nil { break @@ -2877,6 +2912,34 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.TranslationStats.ID(childComplexity), true + case "TranslationStats.likes": + if e.complexity.TranslationStats.Likes == nil { + break + } + + return e.complexity.TranslationStats.Likes(childComplexity), true + + case "TranslationStats.readingTime": + if e.complexity.TranslationStats.ReadingTime == nil { + break + } + + return e.complexity.TranslationStats.ReadingTime(childComplexity), true + + case "TranslationStats.sentiment": + if e.complexity.TranslationStats.Sentiment == nil { + break + } + + return e.complexity.TranslationStats.Sentiment(childComplexity), true + + case "TranslationStats.shares": + if e.complexity.TranslationStats.Shares == nil { + break + } + + return e.complexity.TranslationStats.Shares(childComplexity), true + case "TranslationStats.translation": if e.complexity.TranslationStats.Translation == nil { break @@ -3416,6 +3479,27 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Work.WritingStyle(childComplexity), true + case "WorkStats.bookmarks": + if e.complexity.WorkStats.Bookmarks == nil { + break + } + + return e.complexity.WorkStats.Bookmarks(childComplexity), true + + case "WorkStats.comments": + if e.complexity.WorkStats.Comments == nil { + break + } + + return e.complexity.WorkStats.Comments(childComplexity), true + + case "WorkStats.complexity": + if e.complexity.WorkStats.Complexity == nil { + break + } + + return e.complexity.WorkStats.Complexity(childComplexity), true + case "WorkStats.createdAt": if e.complexity.WorkStats.CreatedAt == nil { break @@ -3430,6 +3514,41 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.WorkStats.ID(childComplexity), true + case "WorkStats.likes": + if e.complexity.WorkStats.Likes == nil { + break + } + + return e.complexity.WorkStats.Likes(childComplexity), true + + case "WorkStats.readingTime": + if e.complexity.WorkStats.ReadingTime == nil { + break + } + + return e.complexity.WorkStats.ReadingTime(childComplexity), true + + case "WorkStats.sentiment": + if e.complexity.WorkStats.Sentiment == nil { + break + } + + return e.complexity.WorkStats.Sentiment(childComplexity), true + + case "WorkStats.shares": + if e.complexity.WorkStats.Shares == nil { + break + } + + return e.complexity.WorkStats.Shares(childComplexity), true + + case "WorkStats.translationCount": + if e.complexity.WorkStats.TranslationCount == nil { + break + } + + return e.complexity.WorkStats.TranslationCount(childComplexity), true + case "WorkStats.updatedAt": if e.complexity.WorkStats.UpdatedAt == nil { break @@ -3507,6 +3626,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputCommentInput, ec.unmarshalInputContributionInput, ec.unmarshalInputLikeInput, + ec.unmarshalInputLoginInput, ec.unmarshalInputRegisterInput, ec.unmarshalInputSearchFilters, ec.unmarshalInputTranslationInput, @@ -3628,2087 +3748,882 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...) // region ***************************** args.gotpl ***************************** +func (ec *executionContext) dir_binding_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "constraint", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["constraint"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_addWorkToCollection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_addWorkToCollection_argsCollectionID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "collectionId", ec.unmarshalNID2string) if err != nil { return nil, err } args["collectionId"] = arg0 - arg1, err := ec.field_Mutation_addWorkToCollection_argsWorkID(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "workId", ec.unmarshalNID2string) if err != nil { return nil, err } args["workId"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_addWorkToCollection_argsCollectionID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("collectionId")) - if tmp, ok := rawArgs["collectionId"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_addWorkToCollection_argsWorkID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("workId")) - if tmp, ok := rawArgs["workId"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_changePassword_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_changePassword_argsCurrentPassword(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "currentPassword", ec.unmarshalNString2string) if err != nil { return nil, err } args["currentPassword"] = arg0 - arg1, err := ec.field_Mutation_changePassword_argsNewPassword(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "newPassword", ec.unmarshalNString2string) if err != nil { return nil, err } args["newPassword"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_changePassword_argsCurrentPassword( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("currentPassword")) - if tmp, ok := rawArgs["currentPassword"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_changePassword_argsNewPassword( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("newPassword")) - if tmp, ok := rawArgs["newPassword"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createAuthor_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createAuthor_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNAuthorInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAuthorInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createAuthor_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.AuthorInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNAuthorInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAuthorInput(ctx, tmp) - } - - var zeroVal model.AuthorInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createBookmark_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createBookmark_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNBookmarkInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookmarkInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createBookmark_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.BookmarkInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNBookmarkInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookmarkInput(ctx, tmp) - } - - var zeroVal model.BookmarkInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createCollection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createCollection_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNCollectionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCollectionInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createCollection_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.CollectionInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNCollectionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCollectionInput(ctx, tmp) - } - - var zeroVal model.CollectionInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createComment_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createComment_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNCommentInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCommentInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createComment_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.CommentInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNCommentInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCommentInput(ctx, tmp) - } - - var zeroVal model.CommentInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createContribution_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createContribution_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNContributionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐContributionInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createContribution_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.ContributionInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNContributionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐContributionInput(ctx, tmp) - } - - var zeroVal model.ContributionInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createLike_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createLike_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNLikeInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐLikeInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createLike_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.LikeInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNLikeInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐLikeInput(ctx, tmp) - } - - var zeroVal model.LikeInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createTranslation_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createTranslation_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNTranslationInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐTranslationInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createTranslation_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.TranslationInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNTranslationInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐTranslationInput(ctx, tmp) - } - - var zeroVal model.TranslationInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_createWork_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_createWork_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNWorkInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐWorkInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_createWork_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.WorkInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNWorkInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐWorkInput(ctx, tmp) - } - - var zeroVal model.WorkInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteAuthor_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteAuthor_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteAuthor_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteBookmark_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteBookmark_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteBookmark_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteCollection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteCollection_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteCollection_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteComment_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteComment_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteComment_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteContribution_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteContribution_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteContribution_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteLike_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteLike_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteLike_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteTranslation_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteTranslation_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteTranslation_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteUser_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteUser_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteUser_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_deleteWork_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_deleteWork_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_deleteWork_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_forgotPassword_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_forgotPassword_argsEmail(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "email", ec.unmarshalNString2string) if err != nil { return nil, err } args["email"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_forgotPassword_argsEmail( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - if tmp, ok := rawArgs["email"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_login_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_login_argsEmail(ctx, rawArgs) - if err != nil { - return nil, err - } - args["email"] = arg0 - arg1, err := ec.field_Mutation_login_argsPassword(ctx, rawArgs) - if err != nil { - return nil, err - } - args["password"] = arg1 - return args, nil -} -func (ec *executionContext) field_Mutation_login_argsEmail( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - if tmp, ok := rawArgs["email"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_login_argsPassword( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) - if tmp, ok := rawArgs["password"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_register_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { - var err error - args := map[string]any{} - arg0, err := ec.field_Mutation_register_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNLoginInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐLoginInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_register_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.RegisterInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNRegisterInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐRegisterInput(ctx, tmp) - } - var zeroVal model.RegisterInput - return zeroVal, nil +func (ec *executionContext) field_Mutation_register_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNRegisterInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐRegisterInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil } func (ec *executionContext) field_Mutation_removeWorkFromCollection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_removeWorkFromCollection_argsCollectionID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "collectionId", ec.unmarshalNID2string) if err != nil { return nil, err } args["collectionId"] = arg0 - arg1, err := ec.field_Mutation_removeWorkFromCollection_argsWorkID(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "workId", ec.unmarshalNID2string) if err != nil { return nil, err } args["workId"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_removeWorkFromCollection_argsCollectionID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("collectionId")) - if tmp, ok := rawArgs["collectionId"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_removeWorkFromCollection_argsWorkID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("workId")) - if tmp, ok := rawArgs["workId"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_resendVerificationEmail_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_resendVerificationEmail_argsEmail(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "email", ec.unmarshalNString2string) if err != nil { return nil, err } args["email"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_resendVerificationEmail_argsEmail( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - if tmp, ok := rawArgs["email"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_resetPassword_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_resetPassword_argsToken(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "token", ec.unmarshalNString2string) if err != nil { return nil, err } args["token"] = arg0 - arg1, err := ec.field_Mutation_resetPassword_argsNewPassword(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "newPassword", ec.unmarshalNString2string) if err != nil { return nil, err } args["newPassword"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_resetPassword_argsToken( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) - if tmp, ok := rawArgs["token"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_resetPassword_argsNewPassword( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("newPassword")) - if tmp, ok := rawArgs["newPassword"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_reviewContribution_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_reviewContribution_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_reviewContribution_argsStatus(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "status", ec.unmarshalNContributionStatus2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐContributionStatus) if err != nil { return nil, err } args["status"] = arg1 - arg2, err := ec.field_Mutation_reviewContribution_argsFeedback(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "feedback", ec.unmarshalOString2ᚖstring) if err != nil { return nil, err } args["feedback"] = arg2 return args, nil } -func (ec *executionContext) field_Mutation_reviewContribution_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_reviewContribution_argsStatus( - ctx context.Context, - rawArgs map[string]any, -) (model.ContributionStatus, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("status")) - if tmp, ok := rawArgs["status"]; ok { - return ec.unmarshalNContributionStatus2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐContributionStatus(ctx, tmp) - } - - var zeroVal model.ContributionStatus - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_reviewContribution_argsFeedback( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("feedback")) - if tmp, ok := rawArgs["feedback"]; ok { - return ec.unmarshalOString2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateAuthor_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateAuthor_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_updateAuthor_argsInput(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNAuthorInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAuthorInput) if err != nil { return nil, err } args["input"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_updateAuthor_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_updateAuthor_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.AuthorInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNAuthorInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAuthorInput(ctx, tmp) - } - - var zeroVal model.AuthorInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateCollection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateCollection_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_updateCollection_argsInput(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNCollectionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCollectionInput) if err != nil { return nil, err } args["input"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_updateCollection_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_updateCollection_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.CollectionInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNCollectionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCollectionInput(ctx, tmp) - } - - var zeroVal model.CollectionInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateComment_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateComment_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_updateComment_argsInput(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNCommentInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCommentInput) if err != nil { return nil, err } args["input"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_updateComment_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_updateComment_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.CommentInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNCommentInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐCommentInput(ctx, tmp) - } - - var zeroVal model.CommentInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateContribution_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateContribution_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_updateContribution_argsInput(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNContributionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐContributionInput) if err != nil { return nil, err } args["input"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_updateContribution_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_updateContribution_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.ContributionInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNContributionInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐContributionInput(ctx, tmp) - } - - var zeroVal model.ContributionInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateProfile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateProfile_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNUserInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐUserInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_updateProfile_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.UserInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNUserInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐUserInput(ctx, tmp) - } - - var zeroVal model.UserInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateTranslation_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateTranslation_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_updateTranslation_argsInput(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNTranslationInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐTranslationInput) if err != nil { return nil, err } args["input"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_updateTranslation_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_updateTranslation_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.TranslationInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNTranslationInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐTranslationInput(ctx, tmp) - } - - var zeroVal model.TranslationInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateUser_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateUser_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_updateUser_argsInput(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNUserInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐUserInput) if err != nil { return nil, err } args["input"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_updateUser_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_updateUser_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.UserInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNUserInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐUserInput(ctx, tmp) - } - - var zeroVal model.UserInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_updateWork_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_updateWork_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 - arg1, err := ec.field_Mutation_updateWork_argsInput(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNWorkInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐWorkInput) if err != nil { return nil, err } args["input"] = arg1 return args, nil } -func (ec *executionContext) field_Mutation_updateWork_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Mutation_updateWork_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (model.WorkInput, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNWorkInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐWorkInput(ctx, tmp) - } - - var zeroVal model.WorkInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_verifyEmail_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_verifyEmail_argsToken(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "token", ec.unmarshalNString2string) if err != nil { return nil, err } args["token"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_verifyEmail_argsToken( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) - if tmp, ok := rawArgs["token"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query___type_argsName(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "name", ec.unmarshalNString2string) if err != nil { return nil, err } args["name"] = arg0 return args, nil } -func (ec *executionContext) field_Query___type_argsName( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) - if tmp, ok := rawArgs["name"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_author_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_author_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_author_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_authors_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_authors_argsLimit(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg0 - arg1, err := ec.field_Query_authors_argsOffset(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg1 - arg2, err := ec.field_Query_authors_argsSearch(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "search", ec.unmarshalOString2ᚖstring) if err != nil { return nil, err } args["search"] = arg2 - arg3, err := ec.field_Query_authors_argsCountryID(ctx, rawArgs) + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "countryId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["countryId"] = arg3 return args, nil } -func (ec *executionContext) field_Query_authors_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_authors_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_authors_argsSearch( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("search")) - if tmp, ok := rawArgs["search"]; ok { - return ec.unmarshalOString2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_authors_argsCountryID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("countryId")) - if tmp, ok := rawArgs["countryId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} func (ec *executionContext) field_Query_categories_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_categories_argsLimit(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg0 - arg1, err := ec.field_Query_categories_argsOffset(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg1 return args, nil } -func (ec *executionContext) field_Query_categories_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_categories_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} func (ec *executionContext) field_Query_category_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_category_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_category_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_collection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_collection_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_collection_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_collections_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_collections_argsUserID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "userId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["userId"] = arg0 - arg1, err := ec.field_Query_collections_argsLimit(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg1 - arg2, err := ec.field_Query_collections_argsOffset(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg2 return args, nil } -func (ec *executionContext) field_Query_collections_argsUserID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("userId")) - if tmp, ok := rawArgs["userId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_collections_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_collections_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} func (ec *executionContext) field_Query_comment_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_comment_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_comment_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_comments_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_comments_argsWorkID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "workId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["workId"] = arg0 - arg1, err := ec.field_Query_comments_argsTranslationID(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "translationId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["translationId"] = arg1 - arg2, err := ec.field_Query_comments_argsUserID(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "userId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["userId"] = arg2 - arg3, err := ec.field_Query_comments_argsLimit(ctx, rawArgs) + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg3 - arg4, err := ec.field_Query_comments_argsOffset(ctx, rawArgs) + arg4, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg4 return args, nil } -func (ec *executionContext) field_Query_comments_argsWorkID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("workId")) - if tmp, ok := rawArgs["workId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_comments_argsTranslationID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("translationId")) - if tmp, ok := rawArgs["translationId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_comments_argsUserID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("userId")) - if tmp, ok := rawArgs["userId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_comments_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_comments_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} func (ec *executionContext) field_Query_search_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_search_argsQuery(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "query", ec.unmarshalNString2string) if err != nil { return nil, err } args["query"] = arg0 - arg1, err := ec.field_Query_search_argsLimit(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg1 - arg2, err := ec.field_Query_search_argsOffset(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg2 - arg3, err := ec.field_Query_search_argsFilters(ctx, rawArgs) + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "filters", ec.unmarshalOSearchFilters2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐSearchFilters) if err != nil { return nil, err } args["filters"] = arg3 return args, nil } -func (ec *executionContext) field_Query_search_argsQuery( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("query")) - if tmp, ok := rawArgs["query"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_search_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_search_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_search_argsFilters( - ctx context.Context, - rawArgs map[string]any, -) (*model.SearchFilters, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("filters")) - if tmp, ok := rawArgs["filters"]; ok { - return ec.unmarshalOSearchFilters2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐSearchFilters(ctx, tmp) - } - - var zeroVal *model.SearchFilters - return zeroVal, nil -} func (ec *executionContext) field_Query_tag_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_tag_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_tag_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_tags_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_tags_argsLimit(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg0 - arg1, err := ec.field_Query_tags_argsOffset(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg1 return args, nil } -func (ec *executionContext) field_Query_tags_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_tags_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} func (ec *executionContext) field_Query_translation_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_translation_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_translation_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_translations_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_translations_argsWorkID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "workId", ec.unmarshalNID2string) if err != nil { return nil, err } args["workId"] = arg0 - arg1, err := ec.field_Query_translations_argsLanguage(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "language", ec.unmarshalOString2ᚖstring) if err != nil { return nil, err } args["language"] = arg1 - arg2, err := ec.field_Query_translations_argsLimit(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg2 - arg3, err := ec.field_Query_translations_argsOffset(ctx, rawArgs) + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg3 return args, nil } -func (ec *executionContext) field_Query_translations_argsWorkID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("workId")) - if tmp, ok := rawArgs["workId"]; ok { - return ec.unmarshalNID2string(ctx, tmp) + +func (ec *executionContext) field_Query_trendingWorks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "timePeriod", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err } - - var zeroVal string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_translations_argsLanguage( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("language")) - if tmp, ok := rawArgs["language"]; ok { - return ec.unmarshalOString2ᚖstring(ctx, tmp) + args["timePeriod"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) + if err != nil { + return nil, err } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_translations_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_translations_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil + args["limit"] = arg1 + return args, nil } func (ec *executionContext) field_Query_userByEmail_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_userByEmail_argsEmail(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "email", ec.unmarshalNString2string) if err != nil { return nil, err } args["email"] = arg0 return args, nil } -func (ec *executionContext) field_Query_userByEmail_argsEmail( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - if tmp, ok := rawArgs["email"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_userByUsername_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_userByUsername_argsUsername(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "username", ec.unmarshalNString2string) if err != nil { return nil, err } args["username"] = arg0 return args, nil } -func (ec *executionContext) field_Query_userByUsername_argsUsername( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("username")) - if tmp, ok := rawArgs["username"]; ok { - return ec.unmarshalNString2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_userProfile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_userProfile_argsUserID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "userId", ec.unmarshalNID2string) if err != nil { return nil, err } args["userId"] = arg0 return args, nil } -func (ec *executionContext) field_Query_userProfile_argsUserID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("userId")) - if tmp, ok := rawArgs["userId"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_user_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_user_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_user_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_users_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_users_argsLimit(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg0 - arg1, err := ec.field_Query_users_argsOffset(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg1 - arg2, err := ec.field_Query_users_argsRole(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "role", ec.unmarshalOUserRole2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐUserRole) if err != nil { return nil, err } args["role"] = arg2 return args, nil } -func (ec *executionContext) field_Query_users_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_users_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_users_argsRole( - ctx context.Context, - rawArgs map[string]any, -) (*model.UserRole, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("role")) - if tmp, ok := rawArgs["role"]; ok { - return ec.unmarshalOUserRole2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐUserRole(ctx, tmp) - } - - var zeroVal *model.UserRole - return zeroVal, nil -} func (ec *executionContext) field_Query_work_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_work_argsID(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) if err != nil { return nil, err } args["id"] = arg0 return args, nil } -func (ec *executionContext) field_Query_work_argsID( - ctx context.Context, - rawArgs map[string]any, -) (string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) - if tmp, ok := rawArgs["id"]; ok { - return ec.unmarshalNID2string(ctx, tmp) - } - - var zeroVal string - return zeroVal, nil -} func (ec *executionContext) field_Query_works_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Query_works_argsLimit(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["limit"] = arg0 - arg1, err := ec.field_Query_works_argsOffset(ctx, rawArgs) + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "offset", ec.unmarshalOInt2ᚖint32) if err != nil { return nil, err } args["offset"] = arg1 - arg2, err := ec.field_Query_works_argsLanguage(ctx, rawArgs) + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "language", ec.unmarshalOString2ᚖstring) if err != nil { return nil, err } args["language"] = arg2 - arg3, err := ec.field_Query_works_argsAuthorID(ctx, rawArgs) + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "authorId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["authorId"] = arg3 - arg4, err := ec.field_Query_works_argsCategoryID(ctx, rawArgs) + arg4, err := graphql.ProcessArgField(ctx, rawArgs, "categoryId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["categoryId"] = arg4 - arg5, err := ec.field_Query_works_argsTagID(ctx, rawArgs) + arg5, err := graphql.ProcessArgField(ctx, rawArgs, "tagId", ec.unmarshalOID2ᚖstring) if err != nil { return nil, err } args["tagId"] = arg5 - arg6, err := ec.field_Query_works_argsSearch(ctx, rawArgs) + arg6, err := graphql.ProcessArgField(ctx, rawArgs, "search", ec.unmarshalOString2ᚖstring) if err != nil { return nil, err } args["search"] = arg6 return args, nil } -func (ec *executionContext) field_Query_works_argsLimit( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) - if tmp, ok := rawArgs["limit"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_works_argsOffset( - ctx context.Context, - rawArgs map[string]any, -) (*int32, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) - if tmp, ok := rawArgs["offset"]; ok { - return ec.unmarshalOInt2ᚖint32(ctx, tmp) - } - - var zeroVal *int32 - return zeroVal, nil -} - -func (ec *executionContext) field_Query_works_argsLanguage( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("language")) - if tmp, ok := rawArgs["language"]; ok { - return ec.unmarshalOString2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_works_argsAuthorID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("authorId")) - if tmp, ok := rawArgs["authorId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_works_argsCategoryID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("categoryId")) - if tmp, ok := rawArgs["categoryId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_works_argsTagID( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("tagId")) - if tmp, ok := rawArgs["tagId"]; ok { - return ec.unmarshalOID2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} - -func (ec *executionContext) field_Query_works_argsSearch( - ctx context.Context, - rawArgs map[string]any, -) (*string, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("search")) - if tmp, ok := rawArgs["search"]; ok { - return ec.unmarshalOString2ᚖstring(ctx, tmp) - } - - var zeroVal *string - return zeroVal, nil -} func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field___Directive_args_argsIncludeDeprecated(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2ᚖbool) if err != nil { return nil, err } args["includeDeprecated"] = arg0 return args, nil } -func (ec *executionContext) field___Directive_args_argsIncludeDeprecated( - ctx context.Context, - rawArgs map[string]any, -) (*bool, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) - if tmp, ok := rawArgs["includeDeprecated"]; ok { - return ec.unmarshalOBoolean2ᚖbool(ctx, tmp) - } - - var zeroVal *bool - return zeroVal, nil -} func (ec *executionContext) field___Field_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field___Field_args_argsIncludeDeprecated(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2ᚖbool) if err != nil { return nil, err } args["includeDeprecated"] = arg0 return args, nil } -func (ec *executionContext) field___Field_args_argsIncludeDeprecated( - ctx context.Context, - rawArgs map[string]any, -) (*bool, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) - if tmp, ok := rawArgs["includeDeprecated"]; ok { - return ec.unmarshalOBoolean2ᚖbool(ctx, tmp) - } - - var zeroVal *bool - return zeroVal, nil -} func (ec *executionContext) field___Type_enumValues_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field___Type_enumValues_argsIncludeDeprecated(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2bool) if err != nil { return nil, err } args["includeDeprecated"] = arg0 return args, nil } -func (ec *executionContext) field___Type_enumValues_argsIncludeDeprecated( - ctx context.Context, - rawArgs map[string]any, -) (bool, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) - if tmp, ok := rawArgs["includeDeprecated"]; ok { - return ec.unmarshalOBoolean2bool(ctx, tmp) - } - - var zeroVal bool - return zeroVal, nil -} func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field___Type_fields_argsIncludeDeprecated(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2bool) if err != nil { return nil, err } args["includeDeprecated"] = arg0 return args, nil } -func (ec *executionContext) field___Type_fields_argsIncludeDeprecated( - ctx context.Context, - rawArgs map[string]any, -) (bool, error) { - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("includeDeprecated")) - if tmp, ok := rawArgs["includeDeprecated"]; ok { - return ec.unmarshalOBoolean2bool(ctx, tmp) - } - - var zeroVal bool - return zeroVal, nil -} // endregion ***************************** args.gotpl ***************************** @@ -14806,7 +13721,7 @@ func (ec *executionContext) _Mutation_login(ctx context.Context, field graphql.C }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().Login(rctx, fc.Args["email"].(string), fc.Args["password"].(string)) + return ec.resolvers.Mutation().Login(rctx, fc.Args["input"].(model.LoginInput)) }) if err != nil { ec.Error(ctx, err) @@ -19795,6 +18710,115 @@ func (ec *executionContext) fieldContext_Query_search(ctx context.Context, field return fc, nil } +func (ec *executionContext) _Query_trendingWorks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_trendingWorks(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().TrendingWorks(rctx, fc.Args["timePeriod"].(*string), fc.Args["limit"].(*int32)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*model.Work) + fc.Result = res + return ec.marshalNWork2ᚕᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐWorkᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_trendingWorks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Work_id(ctx, field) + case "name": + return ec.fieldContext_Work_name(ctx, field) + case "language": + return ec.fieldContext_Work_language(ctx, field) + case "content": + return ec.fieldContext_Work_content(ctx, field) + case "createdAt": + return ec.fieldContext_Work_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Work_updatedAt(ctx, field) + case "translations": + return ec.fieldContext_Work_translations(ctx, field) + case "authors": + return ec.fieldContext_Work_authors(ctx, field) + case "tags": + return ec.fieldContext_Work_tags(ctx, field) + case "categories": + return ec.fieldContext_Work_categories(ctx, field) + case "readabilityScore": + return ec.fieldContext_Work_readabilityScore(ctx, field) + case "writingStyle": + return ec.fieldContext_Work_writingStyle(ctx, field) + case "emotions": + return ec.fieldContext_Work_emotions(ctx, field) + case "topicClusters": + return ec.fieldContext_Work_topicClusters(ctx, field) + case "moods": + return ec.fieldContext_Work_moods(ctx, field) + case "concepts": + return ec.fieldContext_Work_concepts(ctx, field) + case "linguisticLayers": + return ec.fieldContext_Work_linguisticLayers(ctx, field) + case "stats": + return ec.fieldContext_Work_stats(ctx, field) + case "textMetadata": + return ec.fieldContext_Work_textMetadata(ctx, field) + case "poeticAnalysis": + return ec.fieldContext_Work_poeticAnalysis(ctx, field) + case "copyright": + return ec.fieldContext_Work_copyright(ctx, field) + case "copyrightClaims": + return ec.fieldContext_Work_copyrightClaims(ctx, field) + case "collections": + return ec.fieldContext_Work_collections(ctx, field) + case "comments": + return ec.fieldContext_Work_comments(ctx, field) + case "likes": + return ec.fieldContext_Work_likes(ctx, field) + case "bookmarks": + return ec.fieldContext_Work_bookmarks(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Work", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_trendingWorks_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query___type(ctx, field) if err != nil { @@ -22370,6 +21394,16 @@ func (ec *executionContext) fieldContext_Translation_stats(_ context.Context, fi return ec.fieldContext_TranslationStats_id(ctx, field) case "views": return ec.fieldContext_TranslationStats_views(ctx, field) + case "likes": + return ec.fieldContext_TranslationStats_likes(ctx, field) + case "comments": + return ec.fieldContext_TranslationStats_comments(ctx, field) + case "shares": + return ec.fieldContext_TranslationStats_shares(ctx, field) + case "readingTime": + return ec.fieldContext_TranslationStats_readingTime(ctx, field) + case "sentiment": + return ec.fieldContext_TranslationStats_sentiment(ctx, field) case "createdAt": return ec.fieldContext_TranslationStats_createdAt(ctx, field) case "updatedAt": @@ -22696,14 +21730,11 @@ func (ec *executionContext) _TranslationStats_views(ctx context.Context, field g return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } - res := resTmp.(int32) + res := resTmp.(*int32) fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_TranslationStats_views(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -22719,6 +21750,211 @@ func (ec *executionContext) fieldContext_TranslationStats_views(_ context.Contex return fc, nil } +func (ec *executionContext) _TranslationStats_likes(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_likes(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Likes, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_likes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_comments(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_comments(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Comments, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_comments(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_shares(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_shares(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Shares, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_shares(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_readingTime(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_readingTime(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ReadingTime, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_readingTime(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_sentiment(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_sentiment(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Sentiment, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_sentiment(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _TranslationStats_createdAt(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { fc, err := ec.fieldContext_TranslationStats_createdAt(ctx, field) if err != nil { @@ -26207,6 +25443,22 @@ func (ec *executionContext) fieldContext_Work_stats(_ context.Context, field gra return ec.fieldContext_WorkStats_id(ctx, field) case "views": return ec.fieldContext_WorkStats_views(ctx, field) + case "likes": + return ec.fieldContext_WorkStats_likes(ctx, field) + case "comments": + return ec.fieldContext_WorkStats_comments(ctx, field) + case "bookmarks": + return ec.fieldContext_WorkStats_bookmarks(ctx, field) + case "shares": + return ec.fieldContext_WorkStats_shares(ctx, field) + case "translationCount": + return ec.fieldContext_WorkStats_translationCount(ctx, field) + case "readingTime": + return ec.fieldContext_WorkStats_readingTime(ctx, field) + case "complexity": + return ec.fieldContext_WorkStats_complexity(ctx, field) + case "sentiment": + return ec.fieldContext_WorkStats_sentiment(ctx, field) case "createdAt": return ec.fieldContext_WorkStats_createdAt(ctx, field) case "updatedAt": @@ -26757,14 +26009,11 @@ func (ec *executionContext) _WorkStats_views(ctx context.Context, field graphql. return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } - res := resTmp.(int32) + res := resTmp.(*int32) fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_WorkStats_views(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -26780,6 +26029,334 @@ func (ec *executionContext) fieldContext_WorkStats_views(_ context.Context, fiel return fc, nil } +func (ec *executionContext) _WorkStats_likes(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_likes(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Likes, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_likes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_comments(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_comments(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Comments, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_comments(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_bookmarks(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_bookmarks(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Bookmarks, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_bookmarks(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_shares(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_shares(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Shares, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_shares(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_translationCount(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_translationCount(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.TranslationCount, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_translationCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_readingTime(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_readingTime(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ReadingTime, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_readingTime(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_complexity(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_complexity(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Complexity, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_complexity(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_sentiment(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_sentiment(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Sentiment, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_sentiment(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _WorkStats_createdAt(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { fc, err := ec.fieldContext_WorkStats_createdAt(ctx, field) if err != nil { @@ -29534,6 +29111,40 @@ func (ec *executionContext) unmarshalInputLikeInput(ctx context.Context, obj any return it, nil } +func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj any) (model.LoginInput, error) { + var it model.LoginInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"email", "password"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "email": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "password": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Password = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputRegisterInput(ctx context.Context, obj any) (model.RegisterInput, error) { var it model.RegisterInput asMap := map[string]any{} @@ -32097,6 +31708,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "trendingWorks": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_trendingWorks(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { @@ -32586,9 +32219,16 @@ func (ec *executionContext) _TranslationStats(ctx context.Context, sel ast.Selec } case "views": out.Values[i] = ec._TranslationStats_views(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } + case "likes": + out.Values[i] = ec._TranslationStats_likes(ctx, field, obj) + case "comments": + out.Values[i] = ec._TranslationStats_comments(ctx, field, obj) + case "shares": + out.Values[i] = ec._TranslationStats_shares(ctx, field, obj) + case "readingTime": + out.Values[i] = ec._TranslationStats_readingTime(ctx, field, obj) + case "sentiment": + out.Values[i] = ec._TranslationStats_sentiment(ctx, field, obj) case "createdAt": out.Values[i] = ec._TranslationStats_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -33044,9 +32684,22 @@ func (ec *executionContext) _WorkStats(ctx context.Context, sel ast.SelectionSet } case "views": out.Values[i] = ec._WorkStats_views(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } + case "likes": + out.Values[i] = ec._WorkStats_likes(ctx, field, obj) + case "comments": + out.Values[i] = ec._WorkStats_comments(ctx, field, obj) + case "bookmarks": + out.Values[i] = ec._WorkStats_bookmarks(ctx, field, obj) + case "shares": + out.Values[i] = ec._WorkStats_shares(ctx, field, obj) + case "translationCount": + out.Values[i] = ec._WorkStats_translationCount(ctx, field, obj) + case "readingTime": + out.Values[i] = ec._WorkStats_readingTime(ctx, field, obj) + case "complexity": + out.Values[i] = ec._WorkStats_complexity(ctx, field, obj) + case "sentiment": + out.Values[i] = ec._WorkStats_sentiment(ctx, field, obj) case "createdAt": out.Values[i] = ec._WorkStats_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -33593,6 +33246,7 @@ func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) ( } func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.SelectionSet, v bool) graphql.Marshaler { + _ = sel res := graphql.MarshalBoolean(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -33847,6 +33501,7 @@ func (ec *executionContext) unmarshalNFloat2float64(ctx context.Context, v any) } func (ec *executionContext) marshalNFloat2float64(ctx context.Context, sel ast.SelectionSet, v float64) graphql.Marshaler { + _ = sel res := graphql.MarshalFloatContext(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -33862,6 +33517,7 @@ func (ec *executionContext) unmarshalNID2string(ctx context.Context, v any) (str } func (ec *executionContext) marshalNID2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel res := graphql.MarshalID(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -33877,6 +33533,7 @@ func (ec *executionContext) unmarshalNInt2int32(ctx context.Context, v any) (int } func (ec *executionContext) marshalNInt2int32(ctx context.Context, sel ast.SelectionSet, v int32) graphql.Marshaler { + _ = sel res := graphql.MarshalInt32(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -33915,6 +33572,11 @@ func (ec *executionContext) marshalNLinguisticLayer2ᚖterculᚋinternalᚋadapt return ec._LinguisticLayer(ctx, sel, v) } +func (ec *executionContext) unmarshalNLoginInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐLoginInput(ctx context.Context, v any) (model.LoginInput, error) { + res, err := ec.unmarshalInputLoginInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNMood2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐMood(ctx context.Context, sel ast.SelectionSet, v *model.Mood) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -33960,6 +33622,7 @@ func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) } func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel res := graphql.MarshalString(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -34296,6 +33959,7 @@ func (ec *executionContext) unmarshalN__DirectiveLocation2string(ctx context.Con } func (ec *executionContext) marshalN__DirectiveLocation2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel res := graphql.MarshalString(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -34484,6 +34148,7 @@ func (ec *executionContext) unmarshalN__TypeKind2string(ctx context.Context, v a } func (ec *executionContext) marshalN__TypeKind2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel res := graphql.MarshalString(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -34668,6 +34333,8 @@ func (ec *executionContext) unmarshalOBoolean2bool(ctx context.Context, v any) ( } func (ec *executionContext) marshalOBoolean2bool(ctx context.Context, sel ast.SelectionSet, v bool) graphql.Marshaler { + _ = sel + _ = ctx res := graphql.MarshalBoolean(v) return res } @@ -34684,6 +34351,8 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast if v == nil { return graphql.Null } + _ = sel + _ = ctx res := graphql.MarshalBoolean(*v) return res } @@ -35089,6 +34758,23 @@ func (ec *executionContext) marshalOEmotion2ᚕᚖterculᚋinternalᚋadapters return ret } +func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v any) (*float64, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalFloatContext(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOFloat2ᚖfloat64(ctx context.Context, sel ast.SelectionSet, v *float64) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + res := graphql.MarshalFloatContext(*v) + return graphql.WrapContextMarshaler(ctx, res) +} + func (ec *executionContext) unmarshalOID2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { if v == nil { return nil, nil @@ -35137,6 +34823,8 @@ func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.Se if v == nil { return graphql.Null } + _ = sel + _ = ctx res := graphql.MarshalID(*v) return res } @@ -35153,6 +34841,8 @@ func (ec *executionContext) marshalOInt2ᚖint32(ctx context.Context, sel ast.Se if v == nil { return graphql.Null } + _ = sel + _ = ctx res := graphql.MarshalInt32(*v) return res } @@ -35169,6 +34859,8 @@ func (ec *executionContext) marshalOJSON2ᚖstring(ctx context.Context, sel ast. if v == nil { return graphql.Null } + _ = sel + _ = ctx res := graphql.MarshalString(*v) return res } @@ -35445,6 +35137,8 @@ func (ec *executionContext) marshalOString2ᚖstring(ctx context.Context, sel as if v == nil { return graphql.Null } + _ = sel + _ = ctx res := graphql.MarshalString(*v) return res } diff --git a/internal/adapters/graphql/integration_test.go b/internal/adapters/graphql/integration_test.go index 58b1574..b3c476a 100644 --- a/internal/adapters/graphql/integration_test.go +++ b/internal/adapters/graphql/integration_test.go @@ -7,9 +7,12 @@ import ( "fmt" "net/http" "net/http/httptest" + "strconv" "testing" graph "tercul/internal/adapters/graphql" + "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" "tercul/internal/testutil" "github.com/99designs/gqlgen/graphql/handler" @@ -23,47 +26,52 @@ type GraphQLRequest struct { Variables map[string]interface{} `json:"variables,omitempty"` } -// GraphQLResponse represents a GraphQL response -type GraphQLResponse struct { - Data map[string]interface{} `json:"data,omitempty"` +// GraphQLResponse represents a generic GraphQL response +type GraphQLResponse[T any] struct { + Data T `json:"data,omitempty"` Errors []map[string]interface{} `json:"errors,omitempty"` } // GraphQLIntegrationSuite is a test suite for GraphQL integration tests type GraphQLIntegrationSuite struct { - testutil.SimpleTestSuite + testutil.IntegrationTestSuite server *httptest.Server client *http.Client } // SetupSuite sets up the test suite func (s *GraphQLIntegrationSuite) SetupSuite() { - // Use the simple test utilities - s.SimpleTestSuite.SetupSuite() + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) // 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) { + // Create JWT manager and middleware + jwtManager := platform_auth.NewJWTManager() + authMiddleware := platform_auth.GraphQLAuthMiddleware(jwtManager) + + s.server = httptest.NewServer(authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) - })) + }))) s.client = s.server.Client() } // TearDownSuite tears down the test suite func (s *GraphQLIntegrationSuite) TearDownSuite() { + s.IntegrationTestSuite.TearDownSuite() s.server.Close() } // SetupTest sets up each test func (s *GraphQLIntegrationSuite) SetupTest() { - s.SimpleTestSuite.SetupTest() + s.IntegrationTestSuite.SetupTest() + s.DB.Exec("DELETE FROM trendings") } -// executeGraphQL executes a GraphQL query -func (s *GraphQLIntegrationSuite) executeGraphQL(query string, variables map[string]interface{}) (*GraphQLResponse, error) { +// executeGraphQL executes a GraphQL query and decodes the response into a generic type +func executeGraphQL[T any](s *GraphQLIntegrationSuite, query string, variables map[string]interface{}, token *string) (*GraphQLResponse[T], error) { // Create the request request := GraphQLRequest{ Query: query, @@ -82,6 +90,9 @@ func (s *GraphQLIntegrationSuite) executeGraphQL(query string, variables map[str return nil, err } req.Header.Set("Content-Type", "application/json") + if token != nil { + req.Header.Set("Authorization", "Bearer "+*token) + } // Execute the request resp, err := s.client.Do(req) @@ -91,7 +102,7 @@ func (s *GraphQLIntegrationSuite) executeGraphQL(query string, variables map[str defer resp.Body.Close() // Parse the response - var response GraphQLResponse + var response GraphQLResponse[T] err = json.NewDecoder(resp.Body).Decode(&response) if err != nil { return nil, err @@ -100,6 +111,15 @@ func (s *GraphQLIntegrationSuite) executeGraphQL(query string, variables map[str return &response, nil } +type GetWorkResponse struct { + Work struct { + ID string `json:"id"` + Name string `json:"name"` + Language string `json:"language"` + Content string `json:"content"` + } `json:"work"` +} + // TestQueryWork tests the work query func (s *GraphQLIntegrationSuite) TestQueryWork() { // Create a test work with content @@ -123,18 +143,24 @@ func (s *GraphQLIntegrationSuite) TestQueryWork() { } // Execute the query - response, err := s.executeGraphQL(query, variables) + response, err := executeGraphQL[GetWorkResponse](s, query, variables, nil) s.Require().NoError(err) s.Require().NotNil(response) s.Require().Nil(response.Errors, "GraphQL query should not return errors") - s.Require().NotNil(response.Data, "GraphQL query should return data") // Verify the response - 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 for work", workData["content"], "Work content should match") - s.Equal("en", workData["language"], "Work language should match") + s.Equal("Test Work", response.Data.Work.Name, "Work name should match") + s.Equal("Test content for work", response.Data.Work.Content, "Work content should match") + s.Equal("en", response.Data.Work.Language, "Work language should match") +} + +type GetWorksResponse struct { + Works []struct { + ID string `json:"id"` + Name string `json:"name"` + Language string `json:"language"` + Content string `json:"content"` + } `json:"works"` } // TestQueryWorks tests the works query @@ -157,36 +183,29 @@ func (s *GraphQLIntegrationSuite) TestQueryWorks() { ` // Execute the query - response, err := s.executeGraphQL(query, nil) + response, err := executeGraphQL[GetWorksResponse](s, query, nil, nil) s.Require().NoError(err) s.Require().NotNil(response) s.Require().Nil(response.Errors, "GraphQL query should not return errors") - s.Require().NotNil(response.Data, "GraphQL query should return data") // Verify the response - worksData, ok := response.Data["works"].([]interface{}) - s.Require().True(ok, "GraphQL response should contain works data") - s.True(len(worksData) >= 3, "GraphQL response should contain at least 3 works") + s.True(len(response.Data.Works) >= 3, "GraphQL response should contain at least 3 works") // Verify each work foundWork1 := false foundWork2 := false foundWork3 := false - for _, workData := range worksData { - work, ok := workData.(map[string]interface{}) - s.Require().True(ok, "Work data should be a map") - - name := work["name"].(string) - if name == "Test Work 1" { + for _, work := range response.Data.Works { + if work.Name == "Test Work 1" { foundWork1 = true - s.Equal("en", work["language"], "Work 1 language should match") - } else if name == "Test Work 2" { + s.Equal("en", work.Language, "Work 1 language should match") + } else if work.Name == "Test Work 2" { foundWork2 = true - s.Equal("en", work["language"], "Work 2 language should match") - } else if name == "Test Work 3" { + s.Equal("en", work.Language, "Work 2 language should match") + } else if work.Name == "Test Work 3" { foundWork3 = true - s.Equal("fr", work["language"], "Work 3 language should match") + s.Equal("fr", work.Language, "Work 3 language should match") } } @@ -195,6 +214,15 @@ func (s *GraphQLIntegrationSuite) TestQueryWorks() { s.True(foundWork3, "GraphQL response should contain work 3") } +type CreateWorkResponse struct { + CreateWork struct { + ID string `json:"id"` + Name string `json:"name"` + Language string `json:"language"` + Content string `json:"content"` + } `json:"createWork"` +} + // TestCreateWork tests the createWork mutation func (s *GraphQLIntegrationSuite) TestCreateWork() { // Define the mutation @@ -219,38 +247,939 @@ func (s *GraphQLIntegrationSuite) TestCreateWork() { } // Execute the mutation - response, err := s.executeGraphQL(mutation, variables) + response, err := executeGraphQL[CreateWorkResponse](s, mutation, variables, nil) s.Require().NoError(err) s.Require().NotNil(response) s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") - s.Require().NotNil(response.Data, "GraphQL mutation should return data") // Verify the response - workData, ok := response.Data["createWork"].(map[string]interface{}) - s.Require().True(ok, "GraphQL response should contain work data") - s.NotNil(workData["id"], "Work ID should not be nil") - s.Equal("New Test Work", workData["name"], "Work name should match") - s.Equal("en", workData["language"], "Work language should match") - s.Equal("New test content", workData["content"], "Work content should match") + s.NotNil(response.Data.CreateWork.ID, "Work ID should not be nil") + s.Equal("New Test Work", response.Data.CreateWork.Name, "Work name should match") + s.Equal("en", response.Data.CreateWork.Language, "Work language should match") + s.Equal("New test content", response.Data.CreateWork.Content, "Work content should match") // 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()) + workID, err := strconv.ParseUint(response.Data.CreateWork.ID, 10, 64) s.Require().NoError(err) - - var found bool - for _, w := range works { - if w.Title == "New Test Work" { - found = true - s.Equal("en", w.Language, "Work language should be set correctly") - break - } - } - s.True(found, "Work should be created in repository") + createdWork, err := s.App.WorkQueries.Work(context.Background(), uint(workID)) + s.Require().NoError(err) + s.Require().NotNil(createdWork) + s.Equal("New Test Work", createdWork.Title) + s.Equal("en", createdWork.Language) + s.Equal("New test content", createdWork.Content) } // TestGraphQLIntegrationSuite runs the test suite +func (s *GraphQLIntegrationSuite) TestRegisterValidation() { + s.Run("should return error for invalid input", func() { + // Define the mutation + mutation := ` + mutation Register($input: RegisterInput!) { + register(input: $input) { + token + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "username": "a", // Too short + "email": "invalid-email", + "password": "short", + "firstName": "123", + "lastName": "456", + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestLoginValidation() { + s.Run("should return error for invalid input", func() { + // Define the mutation + mutation := ` + mutation Login($input: LoginInput!) { + login(input: $input) { + token + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "email": "invalid-email", + "password": "short", + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestCreateWorkValidation() { + s.Run("should return error for invalid input", func() { + // Define the mutation + mutation := ` + mutation CreateWork($input: WorkInput!) { + createWork(input: $input) { + id + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "name": "a", // Too short + "language": "en-US", // Not 2 chars + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestUpdateWorkValidation() { + s.Run("should return error for invalid input", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Define the mutation + mutation := ` + mutation UpdateWork($id: ID!, $input: WorkInput!) { + updateWork(id: $id, input: $input) { + id + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", work.ID), + "input": map[string]interface{}{ + "name": "a", // Too short + "language": "en-US", // Not 2 chars + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestCreateAuthorValidation() { + s.Run("should return error for invalid input", func() { + // Define the mutation + mutation := ` + mutation CreateAuthor($input: AuthorInput!) { + createAuthor(input: $input) { + id + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "name": "a", // Too short + "language": "en-US", // Not 2 chars + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() { + s.Run("should return error for invalid input", func() { + // Arrange + author, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation UpdateAuthor($id: ID!, $input: AuthorInput!) { + updateAuthor(id: $id, input: $input) { + id + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", author.ID), + "input": map[string]interface{}{ + "name": "a", // Too short + "language": "en-US", // Not 2 chars + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestCreateTranslationValidation() { + s.Run("should return error for invalid input", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Define the mutation + mutation := ` + mutation CreateTranslation($input: TranslationInput!) { + createTranslation(input: $input) { + id + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "name": "a", // Too short + "language": "en-US", // Not 2 chars + "workId": fmt.Sprintf("%d", work.ID), + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() { + s.Run("should return error for invalid input", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ + Title: "Test Translation", + Language: "en", + Content: "Test content", + TranslatableID: work.ID, + TranslatableType: "Work", + }) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation UpdateTranslation($id: ID!, $input: TranslationInput!) { + updateTranslation(id: $id, input: $input) { + id + } + } + ` + + // Define the variables with invalid input + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", translation.ID), + "input": map[string]interface{}{ + "name": "a", // Too short + "language": "en-US", // Not 2 chars + "workId": fmt.Sprintf("%d", work.ID), + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") + s.Len(response.Errors, 1) + }) +} + +func (s *GraphQLIntegrationSuite) TestDeleteWork() { + s.Run("should delete a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Define the mutation + mutation := ` + mutation DeleteWork($id: ID!) { + deleteWork(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", work.ID), + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + s.Require().NotNil(response.Data) + s.True(response.Data.(map[string]interface{})["deleteWork"].(bool)) + + // Verify that the work was actually deleted from the database + _, err = s.App.WorkQueries.Work(context.Background(), work.ID) + s.Require().Error(err) + }) +} + +func (s *GraphQLIntegrationSuite) TestDeleteAuthor() { + s.Run("should delete an author", func() { + // Arrange + author, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation DeleteAuthor($id: ID!) { + deleteAuthor(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", author.ID), + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + s.Require().NotNil(response.Data) + s.True(response.Data.(map[string]interface{})["deleteAuthor"].(bool)) + + // Verify that the author was actually deleted from the database + _, err = s.App.Author.Queries.Author(context.Background(), author.ID) + s.Require().Error(err) + }) +} + +func (s *GraphQLIntegrationSuite) TestDeleteTranslation() { + s.Run("should delete a translation", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ + Title: "Test Translation", + Language: "en", + Content: "Test content", + TranslatableID: work.ID, + TranslatableType: "Work", + }) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation DeleteTranslation($id: ID!) { + deleteTranslation(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", translation.ID), + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + s.Require().NotNil(response.Data) + s.True(response.Data.(map[string]interface{})["deleteTranslation"].(bool)) + + // Verify that the translation was actually deleted from the database + _, err = s.App.Translation.Queries.Translation(context.Background(), translation.ID) + s.Require().Error(err) + }) +} + func TestGraphQLIntegrationSuite(t *testing.T) { testutil.SkipIfShort(t) suite.Run(t, new(GraphQLIntegrationSuite)) } + +type CreateCollectionResponse struct { + CreateCollection struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + } `json:"createCollection"` +} + +type UpdateCollectionResponse struct { + UpdateCollection struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + } `json:"updateCollection"` +} + +type AddWorkToCollectionResponse struct { + AddWorkToCollection struct { + ID string `json:"id"` + } `json:"addWorkToCollection"` +} + +type RemoveWorkFromCollectionResponse struct { + RemoveWorkFromCollection struct { + ID string `json:"id"` + } `json:"removeWorkFromCollection"` +} + +func (s *GraphQLIntegrationSuite) TestCommentMutations() { + // Create users for testing authorization + commenter, commenterToken := s.CreateAuthenticatedUser("commenter", "commenter@test.com", domain.UserRoleReader) + otherUser, otherToken := s.CreateAuthenticatedUser("otheruser", "other@test.com", domain.UserRoleReader) + _ = otherUser + + // Create a work to comment on + work := s.CreateTestWork("Commentable Work", "en", "Some content") + + var commentID string + + s.Run("should create a comment on a work", func() { + // Define the mutation + mutation := ` + mutation CreateComment($input: CommentInput!) { + createComment(input: $input) { + id + text + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "text": "This is a test comment.", + "workId": fmt.Sprintf("%d", work.ID), + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &commenterToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + + // Verify the response + commentData := response.Data.(map[string]interface{})["createComment"].(map[string]interface{}) + s.NotNil(commentData["id"], "Comment ID should not be nil") + commentID = commentData["id"].(string) + s.Equal("This is a test comment.", commentData["text"]) + }) + + s.Run("should update a comment", func() { + // Define the mutation + mutation := ` + mutation UpdateComment($id: ID!, $input: CommentInput!) { + updateComment(id: $id, input: $input) { + id + text + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": commentID, + "input": map[string]interface{}{ + "text": "This is an updated comment.", + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &commenterToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + + // Verify the response + commentData := response.Data.(map[string]interface{})["updateComment"].(map[string]interface{}) + s.Equal("This is an updated comment.", commentData["text"]) + }) + + s.Run("should not update a comment owned by another user", func() { + // Define the mutation + mutation := ` + mutation UpdateComment($id: ID!, $input: CommentInput!) { + updateComment(id: $id, input: $input) { + id + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": commentID, + "input": map[string]interface{}{ + "text": "Attempted Takeover", + }, + } + + // Execute the mutation with the other user's token + response, err := executeGraphQL[any](s, mutation, variables, &otherToken) + s.Require().NoError(err) + s.Require().NotNil(response.Errors) + }) + + s.Run("should delete a comment", func() { + // Create a new comment to delete + comment, err := s.App.Comment.Commands.CreateComment(context.Background(), comment.CreateCommentInput{ + Text: "to be deleted", + UserID: commenter.ID, + WorkID: &work.ID, + }) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation DeleteComment($id: ID!) { + deleteComment(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", comment.ID), + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &commenterToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + s.True(response.Data.(map[string]interface{})["deleteComment"].(bool)) + }) +} + +func (s *GraphQLIntegrationSuite) TestLikeMutations() { + // Create users for testing authorization + liker, likerToken := s.CreateAuthenticatedUser("liker", "liker@test.com", domain.UserRoleReader) + otherUser, otherToken := s.CreateAuthenticatedUser("otheruser", "other@test.com", domain.UserRoleReader) + _ = otherUser + + // Create a work to like + work := s.CreateTestWork("Likeable Work", "en", "Some content") + + var likeID string + + s.Run("should create a like on a work", func() { + // Define the mutation + mutation := ` + mutation CreateLike($input: LikeInput!) { + createLike(input: $input) { + id + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "workId": fmt.Sprintf("%d", work.ID), + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &likerToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + + // Verify the response + likeData := response.Data.(map[string]interface{})["createLike"].(map[string]interface{}) + s.NotNil(likeData["id"], "Like ID should not be nil") + likeID = likeData["id"].(string) + }) + + s.Run("should not delete a like owned by another user", func() { + // Create a like by the original user + like, err := s.App.Like.Commands.CreateLike(context.Background(), like.CreateLikeInput{ + UserID: liker.ID, + WorkID: &work.ID, + }) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation DeleteLike($id: ID!) { + deleteLike(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", like.ID), + } + + // Execute the mutation with the other user's token + response, err := executeGraphQL[any](s, mutation, variables, &otherToken) + s.Require().NoError(err) + s.Require().NotNil(response.Errors) + }) + + s.Run("should delete a like", func() { + // Use the likeID from the create test + // Define the mutation + mutation := ` + mutation DeleteLike($id: ID!) { + deleteLike(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": likeID, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &likerToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + s.True(response.Data.(map[string]interface{})["deleteLike"].(bool)) + }) +} + +func (s *GraphQLIntegrationSuite) TestBookmarkMutations() { + // Create users for testing authorization + bookmarker, bookmarkerToken := s.CreateAuthenticatedUser("bookmarker", "bookmarker@test.com", domain.UserRoleReader) + otherUser, otherToken := s.CreateAuthenticatedUser("otheruser", "other@test.com", domain.UserRoleReader) + _ = otherUser + + // Create a work to bookmark + work := s.CreateTestWork("Bookmarkable Work", "en", "Some content") + + s.Run("should create a bookmark on a work", func() { + // Define the mutation + mutation := ` + mutation CreateBookmark($input: BookmarkInput!) { + createBookmark(input: $input) { + id + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "workId": fmt.Sprintf("%d", work.ID), + }, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &bookmarkerToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + + // Verify the response + bookmarkData := response.Data.(map[string]interface{})["createBookmark"].(map[string]interface{}) + s.NotNil(bookmarkData["id"], "Bookmark ID should not be nil") + + // Cleanup + bookmarkID, err := strconv.ParseUint(bookmarkData["id"].(string), 10, 32) + s.Require().NoError(err) + s.App.Bookmark.Commands.DeleteBookmark(context.Background(), uint(bookmarkID)) + }) + + s.Run("should not delete a bookmark owned by another user", func() { + // Create a bookmark by the original user + bookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{ + UserID: bookmarker.ID, + WorkID: work.ID, + Name: "A Bookmark", + }) + s.Require().NoError(err) + s.T().Cleanup(func() { s.App.Bookmark.Commands.DeleteBookmark(context.Background(), bookmark.ID) }) + + // Define the mutation + mutation := ` + mutation DeleteBookmark($id: ID!) { + deleteBookmark(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", bookmark.ID), + } + + // Execute the mutation with the other user's token + response, err := executeGraphQL[any](s, mutation, variables, &otherToken) + s.Require().NoError(err) + s.Require().NotNil(response.Errors) + }) + + s.Run("should delete a bookmark", func() { + // Create a new bookmark to delete + bookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{ + UserID: bookmarker.ID, + WorkID: work.ID, + Name: "To Be Deleted", + }) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation DeleteBookmark($id: ID!) { + deleteBookmark(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": fmt.Sprintf("%d", bookmark.ID), + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &bookmarkerToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + s.True(response.Data.(map[string]interface{})["deleteBookmark"].(bool)) + }) +} + +type TrendingWorksResponse struct { + TrendingWorks []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"trendingWorks"` +} + +func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() { + s.Run("should return a list of trending works", func() { + // Arrange + work1 := s.CreateTestWork("Work 1", "en", "content") + work2 := s.CreateTestWork("Work 2", "en", "content") + s.DB.Create(&domain.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1}) + s.DB.Create(&domain.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10}) + s.Require().NoError(s.App.AnalyticsService.UpdateTrending(context.Background())) + + // Act + query := ` + query GetTrendingWorks { + trendingWorks { + id + name + } + } + ` + response, err := executeGraphQL[TrendingWorksResponse](s, query, nil, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL query should not return errors") + + // Assert + s.Len(response.Data.TrendingWorks, 2) + s.Equal(fmt.Sprintf("%d", work2.ID), response.Data.TrendingWorks[0].ID) + }) +} + +func (s *GraphQLIntegrationSuite) TestCollectionMutations() { + // Create users for testing authorization + owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader) + otherUser, otherToken := s.CreateAuthenticatedUser("otheruser", "other@test.com", domain.UserRoleReader) + _ = otherUser + + var collectionID string + + s.Run("should create a collection", func() { + // Define the mutation + mutation := ` + mutation CreateCollection($input: CollectionInput!) { + createCollection(input: $input) { + id + name + description + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "name": "My New Collection", + "description": "A collection of my favorite works.", + }, + } + + // Execute the mutation + response, err := executeGraphQL[CreateCollectionResponse](s, mutation, variables, &ownerToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + + // Verify the response + s.NotNil(response.Data.CreateCollection.ID, "Collection ID should not be nil") + collectionID = response.Data.CreateCollection.ID // Save for later tests + s.Equal("My New Collection", response.Data.CreateCollection.Name, "Collection name should match") + s.Equal("A collection of my favorite works.", response.Data.CreateCollection.Description, "Collection description should match") + }) + + s.Run("should update a collection", func() { + // Define the mutation + mutation := ` + mutation UpdateCollection($id: ID!, $input: CollectionInput!) { + updateCollection(id: $id, input: $input) { + id + name + description + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": collectionID, + "input": map[string]interface{}{ + "name": "My Updated Collection", + "description": "An updated description.", + }, + } + + // Execute the mutation + response, err := executeGraphQL[UpdateCollectionResponse](s, mutation, variables, &ownerToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + + // Verify the response + s.Equal("My Updated Collection", response.Data.UpdateCollection.Name) + }) + + s.Run("should not update a collection owned by another user", func() { + // Define the mutation + mutation := ` + mutation UpdateCollection($id: ID!, $input: CollectionInput!) { + updateCollection(id: $id, input: $input) { + id + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": collectionID, + "input": map[string]interface{}{ + "name": "Attempted Takeover", + }, + } + + // Execute the mutation with the other user's token + response, err := executeGraphQL[any](s, mutation, variables, &otherToken) + s.Require().NoError(err) + s.Require().NotNil(response.Errors) + }) + + s.Run("should add a work to a collection", func() { + // Create a work + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Define the mutation + mutation := ` + mutation AddWorkToCollection($collectionId: ID!, $workId: ID!) { + addWorkToCollection(collectionId: $collectionId, workId: $workId) { + id + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "collectionId": collectionID, + "workId": fmt.Sprintf("%d", work.ID), + } + + // Execute the mutation + response, err := executeGraphQL[AddWorkToCollectionResponse](s, mutation, variables, &ownerToken) + s.Require().NoError(err) + s.Require().Nil(response.Errors) + }) + + s.Run("should remove a work from a collection", func() { + // Create a work and add it to the collection first + work := s.CreateTestWork("Another Work", "en", "Some content") + collectionIDInt, err := strconv.ParseUint(collectionID, 10, 64) + s.Require().NoError(err) + err = s.App.Collection.Commands.AddWorkToCollection(context.Background(), collection.AddWorkToCollectionInput{ + CollectionID: uint(collectionIDInt), + WorkID: work.ID, + }) + s.Require().NoError(err) + + // Define the mutation + mutation := ` + mutation RemoveWorkFromCollection($collectionId: ID!, $workId: ID!) { + removeWorkFromCollection(collectionId: $collectionId, workId: $workId) { + id + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "collectionId": collectionID, + "workId": fmt.Sprintf("%d", work.ID), + } + + // Execute the mutation + response, err := executeGraphQL[RemoveWorkFromCollectionResponse](s, mutation, variables, &ownerToken) + s.Require().NoError(err) + s.Require().Nil(response.Errors) + }) + + s.Run("should delete a collection", func() { + // Define the mutation + mutation := ` + mutation DeleteCollection($id: ID!) { + deleteCollection(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": collectionID, + } + + // Execute the mutation + response, err := executeGraphQL[any](s, mutation, variables, &ownerToken) + s.Require().NoError(err) + s.Require().Nil(response.Errors) + s.True(response.Data.(map[string]interface{})["deleteCollection"].(bool)) + }) +} diff --git a/internal/adapters/graphql/model/models_gen.go b/internal/adapters/graphql/model/models_gen.go index 05db6eb..eb96721 100644 --- a/internal/adapters/graphql/model/models_gen.go +++ b/internal/adapters/graphql/model/models_gen.go @@ -268,6 +268,11 @@ type LinguisticLayer struct { Works []*Work `json:"works,omitempty"` } +type LoginInput struct { + Email string `json:"email"` + Password string `json:"password"` +} + type Mood struct { ID string `json:"id"` Name string `json:"name"` @@ -398,7 +403,12 @@ type TranslationInput struct { type TranslationStats struct { ID string `json:"id"` - Views int32 `json:"views"` + Views *int32 `json:"views,omitempty"` + Likes *int32 `json:"likes,omitempty"` + Comments *int32 `json:"comments,omitempty"` + Shares *int32 `json:"shares,omitempty"` + ReadingTime *int32 `json:"readingTime,omitempty"` + Sentiment *float64 `json:"sentiment,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Translation *Translation `json:"translation"` @@ -520,11 +530,19 @@ type WorkInput struct { } type WorkStats struct { - ID string `json:"id"` - Views int32 `json:"views"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Work *Work `json:"work"` + ID string `json:"id"` + Views *int32 `json:"views,omitempty"` + Likes *int32 `json:"likes,omitempty"` + Comments *int32 `json:"comments,omitempty"` + Bookmarks *int32 `json:"bookmarks,omitempty"` + Shares *int32 `json:"shares,omitempty"` + TranslationCount *int32 `json:"translationCount,omitempty"` + ReadingTime *int32 `json:"readingTime,omitempty"` + Complexity *float64 `json:"complexity,omitempty"` + Sentiment *float64 `json:"sentiment,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Work *Work `json:"work"` } type WritingStyle struct { diff --git a/internal/adapters/graphql/schema.graphqls b/internal/adapters/graphql/schema.graphqls index a76705d..6ee2c6f 100644 --- a/internal/adapters/graphql/schema.graphqls +++ b/internal/adapters/graphql/schema.graphqls @@ -289,7 +289,15 @@ type LinguisticLayer { type WorkStats { id: ID! - views: Int! + views: Int + likes: Int + comments: Int + bookmarks: Int + shares: Int + translationCount: Int + readingTime: Int + complexity: Float + sentiment: Float createdAt: String! updatedAt: String! work: Work! @@ -297,7 +305,12 @@ type WorkStats { type TranslationStats { id: ID! - views: Int! + views: Int + likes: Int + comments: Int + shares: Int + readingTime: Int + sentiment: Float createdAt: String! updatedAt: String! translation: Translation! @@ -440,6 +453,8 @@ type Edge { scalar JSON +directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION + # Queries type Query { # Work queries @@ -517,6 +532,8 @@ type Query { offset: Int filters: SearchFilters ): SearchResults! + + trendingWorks(timePeriod: String, limit: Int): [Work!]! } input SearchFilters { @@ -539,7 +556,7 @@ type SearchResults { type Mutation { # Authentication register(input: RegisterInput!): AuthPayload! - login(email: String!, password: String!): AuthPayload! + login(input: LoginInput!): AuthPayload! # Work mutations createWork(input: WorkInput!): Work! @@ -600,6 +617,11 @@ type Mutation { } # Input types +input LoginInput { + email: String! + password: String! +} + input RegisterInput { username: String! email: String! diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index 043630a..e01fbce 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -2,7 +2,7 @@ package graphql // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.72 +// Code generated by github.com/99designs/gqlgen version v0.17.78 import ( "context" @@ -12,6 +12,7 @@ import ( "tercul/internal/adapters/graphql/model" "tercul/internal/app/auth" "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" ) // Register is the resolver for the register field. @@ -49,11 +50,11 @@ func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInp } // Login is the resolver for the login field. -func (r *mutationResolver) Login(ctx context.Context, email string, password string) (*model.AuthPayload, error) { +func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) { // Convert GraphQL input to service input loginInput := auth.LoginInput{ - Email: email, - Password: password, + Email: input.Email, + Password: input.Password, } // Call auth service @@ -81,6 +82,9 @@ 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) { + if err := validateWorkInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) + } // Create domain model work := &domain.Work{ Title: input.Name, @@ -130,42 +134,221 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput // UpdateWork is the resolver for the updateWork field. func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) { - panic(fmt.Errorf("not implemented: UpdateWork - updateWork")) + if err := validateWorkInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) + } + workID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + // Create domain model + work := &domain.Work{ + TranslatableModel: domain.TranslatableModel{ + BaseModel: domain.BaseModel{ID: uint(workID)}, + Language: input.Language, + }, + Title: input.Name, + } + + // Call work service + err = r.App.WorkCommands.UpdateWork(ctx, work) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Work{ + ID: id, + Name: work.Title, + Language: work.Language, + Content: input.Content, + }, nil } // DeleteWork is the resolver for the deleteWork field. func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteWork - deleteWork")) + workID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("invalid work ID: %v", err) + } + + err = r.App.WorkCommands.DeleteWork(ctx, uint(workID)) + if err != nil { + return false, err + } + + return true, nil } // CreateTranslation is the resolver for the createTranslation field. func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) { - panic(fmt.Errorf("not implemented: CreateTranslation - createTranslation")) + if err := validateTranslationInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) + } + workID, err := strconv.ParseUint(input.WorkID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + // Create domain model + translation := &domain.Translation{ + Title: input.Name, + Language: input.Language, + TranslatableID: uint(workID), + TranslatableType: "Work", + } + if input.Content != nil { + translation.Content = *input.Content + } + + // Call translation service + err = r.App.TranslationRepo.Create(ctx, translation) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Translation{ + ID: fmt.Sprintf("%d", translation.ID), + Name: translation.Title, + Language: translation.Language, + Content: &translation.Content, + WorkID: input.WorkID, + }, nil } // UpdateTranslation is the resolver for the updateTranslation field. func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) { - panic(fmt.Errorf("not implemented: UpdateTranslation - updateTranslation")) + if err := validateTranslationInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) + } + translationID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid translation ID: %v", err) + } + + workID, err := strconv.ParseUint(input.WorkID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + // Create domain model + translation := &domain.Translation{ + BaseModel: domain.BaseModel{ID: uint(translationID)}, + Title: input.Name, + Language: input.Language, + TranslatableID: uint(workID), + TranslatableType: "Work", + } + if input.Content != nil { + translation.Content = *input.Content + } + + // Call translation service + err = r.App.TranslationRepo.Update(ctx, translation) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Translation{ + ID: id, + Name: translation.Title, + Language: translation.Language, + Content: &translation.Content, + WorkID: input.WorkID, + }, nil } // DeleteTranslation is the resolver for the deleteTranslation field. func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteTranslation - deleteTranslation")) + translationID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("invalid translation ID: %v", err) + } + + err = r.App.TranslationRepo.Delete(ctx, uint(translationID)) + if err != nil { + return false, err + } + + return true, nil } // CreateAuthor is the resolver for the createAuthor field. func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) { - panic(fmt.Errorf("not implemented: CreateAuthor - createAuthor")) + if err := validateAuthorInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) + } + // Create domain model + author := &domain.Author{ + Name: input.Name, + TranslatableModel: domain.TranslatableModel{ + Language: input.Language, + }, + } + + // Call author service + err := r.App.AuthorRepo.Create(ctx, author) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Author{ + ID: fmt.Sprintf("%d", author.ID), + Name: author.Name, + Language: author.Language, + }, nil } // UpdateAuthor is the resolver for the updateAuthor field. func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) { - panic(fmt.Errorf("not implemented: UpdateAuthor - updateAuthor")) + if err := validateAuthorInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) + } + authorID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid author ID: %v", err) + } + + // Create domain model + author := &domain.Author{ + TranslatableModel: domain.TranslatableModel{ + BaseModel: domain.BaseModel{ID: uint(authorID)}, + Language: input.Language, + }, + Name: input.Name, + } + + // Call author service + err = r.App.AuthorRepo.Update(ctx, author) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Author{ + ID: id, + Name: author.Name, + Language: author.Language, + }, nil } // DeleteAuthor is the resolver for the deleteAuthor field. func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteAuthor - deleteAuthor")) + authorID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("invalid author ID: %v", err) + } + + err = r.App.AuthorRepo.Delete(ctx, uint(authorID)) + if err != nil { + return false, err + } + + return true, nil } // UpdateUser is the resolver for the updateUser field. @@ -180,62 +363,560 @@ func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, err // CreateCollection is the resolver for the createCollection field. func (r *mutationResolver) CreateCollection(ctx context.Context, input model.CollectionInput) (*model.Collection, error) { - panic(fmt.Errorf("not implemented: CreateCollection - createCollection")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Create domain model + collection := &domain.Collection{ + Name: input.Name, + UserID: userID, + } + if input.Description != nil { + collection.Description = *input.Description + } + + // Call collection repository + err := r.App.CollectionRepo.Create(ctx, collection) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Collection{ + ID: fmt.Sprintf("%d", collection.ID), + Name: collection.Name, + Description: &collection.Description, + User: &model.User{ + ID: fmt.Sprintf("%d", userID), + }, + }, nil } // UpdateCollection is the resolver for the updateCollection field. func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, input model.CollectionInput) (*model.Collection, error) { - panic(fmt.Errorf("not implemented: UpdateCollection - updateCollection")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Parse collection ID + collectionID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid collection ID: %v", err) + } + + // Fetch the existing collection + collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID)) + if err != nil { + return nil, err + } + if collection == nil { + return nil, fmt.Errorf("collection not found") + } + + // Check ownership + if collection.UserID != userID { + return nil, fmt.Errorf("unauthorized") + } + + // Update fields + collection.Name = input.Name + if input.Description != nil { + collection.Description = *input.Description + } + + // Call collection repository + err = r.App.CollectionRepo.Update(ctx, collection) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Collection{ + ID: id, + Name: collection.Name, + Description: &collection.Description, + User: &model.User{ + ID: fmt.Sprintf("%d", userID), + }, + }, nil } // DeleteCollection is the resolver for the deleteCollection field. func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteCollection - deleteCollection")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return false, fmt.Errorf("unauthorized") + } + + // Parse collection ID + collectionID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("invalid collection ID: %v", err) + } + + // Fetch the existing collection + collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID)) + if err != nil { + return false, err + } + if collection == nil { + return false, fmt.Errorf("collection not found") + } + + // Check ownership + if collection.UserID != userID { + return false, fmt.Errorf("unauthorized") + } + + // Call collection repository + err = r.App.CollectionRepo.Delete(ctx, uint(collectionID)) + if err != nil { + return false, err + } + + return true, nil } // AddWorkToCollection is the resolver for the addWorkToCollection field. func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID string, workID string) (*model.Collection, error) { - panic(fmt.Errorf("not implemented: AddWorkToCollection - addWorkToCollection")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Parse IDs + collID, err := strconv.ParseUint(collectionID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid collection ID: %v", err) + } + wID, err := strconv.ParseUint(workID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + // Fetch the existing collection + collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + if err != nil { + return nil, err + } + if collection == nil { + return nil, fmt.Errorf("collection not found") + } + + // Check ownership + if collection.UserID != userID { + return nil, fmt.Errorf("unauthorized") + } + + // Add work to collection + err = r.App.CollectionRepo.AddWorkToCollection(ctx, uint(collID), uint(wID)) + if err != nil { + return nil, err + } + + // Fetch the updated collection to return it + updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Collection{ + ID: collectionID, + Name: updatedCollection.Name, + Description: &updatedCollection.Description, + }, nil } // RemoveWorkFromCollection is the resolver for the removeWorkFromCollection field. func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collectionID string, workID string) (*model.Collection, error) { - panic(fmt.Errorf("not implemented: RemoveWorkFromCollection - removeWorkFromCollection")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Parse IDs + collID, err := strconv.ParseUint(collectionID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid collection ID: %v", err) + } + wID, err := strconv.ParseUint(workID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + // Fetch the existing collection + collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + if err != nil { + return nil, err + } + if collection == nil { + return nil, fmt.Errorf("collection not found") + } + + // Check ownership + if collection.UserID != userID { + return nil, fmt.Errorf("unauthorized") + } + + // Remove work from collection + err = r.App.CollectionRepo.RemoveWorkFromCollection(ctx, uint(collID), uint(wID)) + if err != nil { + return nil, err + } + + // Fetch the updated collection to return it + updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Collection{ + ID: collectionID, + Name: updatedCollection.Name, + Description: &updatedCollection.Description, + }, nil } // CreateComment is the resolver for the createComment field. func (r *mutationResolver) CreateComment(ctx context.Context, input model.CommentInput) (*model.Comment, error) { - panic(fmt.Errorf("not implemented: CreateComment - createComment")) + // Custom validation + if (input.WorkID == nil && input.TranslationID == nil) || (input.WorkID != nil && input.TranslationID != nil) { + return nil, fmt.Errorf("must provide either workId or translationId, but not both") + } + + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Create domain model + comment := &domain.Comment{ + Text: input.Text, + UserID: userID, + } + if input.WorkID != nil { + workID, err := strconv.ParseUint(*input.WorkID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + wID := uint(workID) + comment.WorkID = &wID + } + if input.TranslationID != nil { + translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid translation ID: %v", err) + } + tID := uint(translationID) + comment.TranslationID = &tID + } + if input.ParentCommentID != nil { + parentCommentID, err := strconv.ParseUint(*input.ParentCommentID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid parent comment ID: %v", err) + } + pID := uint(parentCommentID) + comment.ParentID = &pID + } + + // Call comment repository + err := r.App.CommentRepo.Create(ctx, comment) + if err != nil { + return nil, err + } + + // Increment analytics + if comment.WorkID != nil { + r.App.AnalyticsService.IncrementWorkComments(ctx, *comment.WorkID) + } + if comment.TranslationID != nil { + r.App.AnalyticsService.IncrementTranslationComments(ctx, *comment.TranslationID) + } + + // Convert to GraphQL model + return &model.Comment{ + ID: fmt.Sprintf("%d", comment.ID), + Text: comment.Text, + User: &model.User{ + ID: fmt.Sprintf("%d", userID), + }, + }, nil } // UpdateComment is the resolver for the updateComment field. func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input model.CommentInput) (*model.Comment, error) { - panic(fmt.Errorf("not implemented: UpdateComment - updateComment")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Parse comment ID + commentID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid comment ID: %v", err) + } + + // Fetch the existing comment + comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID)) + if err != nil { + return nil, err + } + if comment == nil { + return nil, fmt.Errorf("comment not found") + } + + // Check ownership + if comment.UserID != userID { + return nil, fmt.Errorf("unauthorized") + } + + // Update fields + comment.Text = input.Text + + // Call comment repository + err = r.App.CommentRepo.Update(ctx, comment) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.Comment{ + ID: id, + Text: comment.Text, + User: &model.User{ + ID: fmt.Sprintf("%d", userID), + }, + }, nil } // DeleteComment is the resolver for the deleteComment field. func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteComment - deleteComment")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return false, fmt.Errorf("unauthorized") + } + + // Parse comment ID + commentID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("invalid comment ID: %v", err) + } + + // Fetch the existing comment + comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID)) + if err != nil { + return false, err + } + if comment == nil { + return false, fmt.Errorf("comment not found") + } + + // Check ownership + if comment.UserID != userID { + return false, fmt.Errorf("unauthorized") + } + + // Call comment repository + err = r.App.CommentRepo.Delete(ctx, uint(commentID)) + if err != nil { + return false, err + } + + return true, nil } // CreateLike is the resolver for the createLike field. func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput) (*model.Like, error) { - panic(fmt.Errorf("not implemented: CreateLike - createLike")) + // Custom validation + if (input.WorkID == nil && input.TranslationID == nil && input.CommentID == nil) || + (input.WorkID != nil && input.TranslationID != nil) || + (input.WorkID != nil && input.CommentID != nil) || + (input.TranslationID != nil && input.CommentID != nil) { + return nil, fmt.Errorf("must provide exactly one of workId, translationId, or commentId") + } + + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Create domain model + like := &domain.Like{ + UserID: userID, + } + if input.WorkID != nil { + workID, err := strconv.ParseUint(*input.WorkID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + wID := uint(workID) + like.WorkID = &wID + } + if input.TranslationID != nil { + translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid translation ID: %v", err) + } + tID := uint(translationID) + like.TranslationID = &tID + } + if input.CommentID != nil { + commentID, err := strconv.ParseUint(*input.CommentID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid comment ID: %v", err) + } + cID := uint(commentID) + like.CommentID = &cID + } + + // Call like repository + err := r.App.LikeRepo.Create(ctx, like) + if err != nil { + return nil, err + } + + // Increment analytics + if like.WorkID != nil { + r.App.AnalyticsService.IncrementWorkLikes(ctx, *like.WorkID) + } + if like.TranslationID != nil { + r.App.AnalyticsService.IncrementTranslationLikes(ctx, *like.TranslationID) + } + + // Convert to GraphQL model + return &model.Like{ + ID: fmt.Sprintf("%d", like.ID), + User: &model.User{ID: fmt.Sprintf("%d", userID)}, + }, nil } // DeleteLike is the resolver for the deleteLike field. func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteLike - deleteLike")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return false, fmt.Errorf("unauthorized") + } + + // Parse like ID + likeID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("invalid like ID: %v", err) + } + + // Fetch the existing like + like, err := r.App.LikeRepo.GetByID(ctx, uint(likeID)) + if err != nil { + return false, err + } + if like == nil { + return false, fmt.Errorf("like not found") + } + + // Check ownership + if like.UserID != userID { + return false, fmt.Errorf("unauthorized") + } + + // Call like repository + err = r.App.LikeRepo.Delete(ctx, uint(likeID)) + if err != nil { + return false, err + } + + return true, nil } // CreateBookmark is the resolver for the createBookmark field. func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.BookmarkInput) (*model.Bookmark, error) { - panic(fmt.Errorf("not implemented: CreateBookmark - createBookmark")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + // Parse work ID + workID, err := strconv.ParseUint(input.WorkID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + // Create domain model + bookmark := &domain.Bookmark{ + UserID: userID, + WorkID: uint(workID), + } + if input.Name != nil { + bookmark.Name = *input.Name + } + + // Call bookmark repository + err = r.App.BookmarkRepo.Create(ctx, bookmark) + if err != nil { + return nil, err + } + + // Increment analytics + r.App.AnalyticsService.IncrementWorkBookmarks(ctx, uint(workID)) + + // Convert to GraphQL model + return &model.Bookmark{ + ID: fmt.Sprintf("%d", bookmark.ID), + Name: &bookmark.Name, + User: &model.User{ID: fmt.Sprintf("%d", userID)}, + Work: &model.Work{ID: fmt.Sprintf("%d", workID)}, + }, nil } // DeleteBookmark is the resolver for the deleteBookmark field. func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteBookmark - deleteBookmark")) + // Get user ID from context + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return false, fmt.Errorf("unauthorized") + } + + // Parse bookmark ID + bookmarkID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("invalid bookmark ID: %v", err) + } + + // Fetch the existing bookmark + bookmark, err := r.App.BookmarkRepo.GetByID(ctx, uint(bookmarkID)) + if err != nil { + return false, err + } + if bookmark == nil { + return false, fmt.Errorf("bookmark not found") + } + + // Check ownership + if bookmark.UserID != userID { + return false, fmt.Errorf("unauthorized") + } + + // Call bookmark repository + err = r.App.BookmarkRepo.Delete(ctx, uint(bookmarkID)) + if err != nil { + return false, err + } + + return true, nil } // CreateContribution is the resolver for the createContribution field. @@ -609,6 +1290,35 @@ func (r *queryResolver) Search(ctx context.Context, query string, limit *int32, panic(fmt.Errorf("not implemented: Search - search")) } +// TrendingWorks is the resolver for the trendingWorks field. +func (r *queryResolver) TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) { + tp := "daily" + if timePeriod != nil { + tp = *timePeriod + } + + l := 10 + if limit != nil { + l = int(*limit) + } + + works, err := r.App.AnalyticsService.GetTrendingWorks(ctx, tp, l) + if err != nil { + return nil, err + } + + var result []*model.Work + for _, w := range works { + result = append(result, &model.Work{ + ID: fmt.Sprintf("%d", w.ID), + Name: w.Title, + Language: w.Language, + }) + } + + return result, nil +} + // Mutation returns MutationResolver implementation. func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } @@ -617,3 +1327,70 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +/* + func (r *Resolver) Work() WorkResolver { return &workResolver{r} } +func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} } +type workResolver struct{ *Resolver } +type translationResolver struct{ *Resolver } +func toInt32(i int64) *int { + val := int(i) + return &val +} +func toInt(i int) *int { + return &i +} +func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkStats, error) { + workID, err := strconv.ParseUint(obj.ID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + stats, err := r.App.AnalyticsService.GetOrCreateWorkStats(ctx, uint(workID)) + if err != nil { + return nil, err + } + + // Convert domain model to GraphQL model + return &model.WorkStats{ + ID: fmt.Sprintf("%d", stats.ID), + Views: toInt32(stats.Views), + Likes: toInt32(stats.Likes), + Comments: toInt32(stats.Comments), + Bookmarks: toInt32(stats.Bookmarks), + Shares: toInt32(stats.Shares), + TranslationCount: toInt32(stats.TranslationCount), + ReadingTime: toInt(stats.ReadingTime), + Complexity: &stats.Complexity, + Sentiment: &stats.Sentiment, + }, nil +} +func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) { + translationID, err := strconv.ParseUint(obj.ID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid translation ID: %v", err) + } + + stats, err := r.App.AnalyticsService.GetOrCreateTranslationStats(ctx, uint(translationID)) + if err != nil { + return nil, err + } + + // Convert domain model to GraphQL model + return &model.TranslationStats{ + ID: fmt.Sprintf("%d", stats.ID), + Views: toInt32(stats.Views), + Likes: toInt32(stats.Likes), + Comments: toInt32(stats.Comments), + Shares: toInt32(stats.Shares), + ReadingTime: toInt(stats.ReadingTime), + Sentiment: &stats.Sentiment, + }, nil +} +*/ diff --git a/internal/adapters/graphql/validation.go b/internal/adapters/graphql/validation.go new file mode 100644 index 0000000..c16f69c --- /dev/null +++ b/internal/adapters/graphql/validation.go @@ -0,0 +1,57 @@ +package graphql + +import ( + "errors" + "fmt" + "strings" + "tercul/internal/adapters/graphql/model" + + "github.com/asaskevich/govalidator" +) + +var ErrValidation = errors.New("validation failed") + +func validateWorkInput(input model.WorkInput) error { + name := strings.TrimSpace(input.Name) + if len(name) < 3 { + return fmt.Errorf("name must be at least 3 characters long") + } + if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { + return fmt.Errorf("name can only contain letters, numbers, and spaces") + } + if len(input.Language) != 2 { + return fmt.Errorf("language must be a 2-character code") + } + return nil +} + +func validateAuthorInput(input model.AuthorInput) error { + name := strings.TrimSpace(input.Name) + if len(name) < 3 { + return fmt.Errorf("name must be at least 3 characters long") + } + if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { + return fmt.Errorf("name can only contain letters, numbers, and spaces") + } + if len(input.Language) != 2 { + return fmt.Errorf("language must be a 2-character code") + } + return nil +} + +func validateTranslationInput(input model.TranslationInput) error { + name := strings.TrimSpace(input.Name) + if len(name) < 3 { + return fmt.Errorf("name must be at least 3 characters long") + } + if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { + return fmt.Errorf("name can only contain letters, numbers, and spaces") + } + if len(input.Language) != 2 { + return fmt.Errorf("language must be a 2-character code") + } + if input.WorkID == "" { + return fmt.Errorf("workId is required") + } + return nil +} diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go new file mode 100644 index 0000000..87e1107 --- /dev/null +++ b/internal/app/analytics/service.go @@ -0,0 +1,301 @@ +package analytics + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "tercul/internal/domain" + "tercul/internal/jobs/linguistics" + "tercul/internal/platform/log" + "time" +) + +type Service interface { + IncrementWorkViews(ctx context.Context, workID uint) error + IncrementWorkLikes(ctx context.Context, workID uint) error + IncrementWorkComments(ctx context.Context, workID uint) error + IncrementWorkBookmarks(ctx context.Context, workID uint) error + IncrementWorkShares(ctx context.Context, workID uint) error + IncrementWorkTranslationCount(ctx context.Context, workID uint) error + IncrementTranslationViews(ctx context.Context, translationID uint) error + IncrementTranslationLikes(ctx context.Context, translationID uint) error + IncrementTranslationComments(ctx context.Context, translationID uint) error + IncrementTranslationShares(ctx context.Context, translationID uint) error + GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) + GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) + + UpdateWorkReadingTime(ctx context.Context, workID uint) error + UpdateWorkComplexity(ctx context.Context, workID uint) error + UpdateWorkSentiment(ctx context.Context, workID uint) error + UpdateTranslationReadingTime(ctx context.Context, translationID uint) error + UpdateTranslationSentiment(ctx context.Context, translationID uint) error + + UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error + UpdateTrending(ctx context.Context) error + GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) +} + +type service struct { + repo domain.AnalyticsRepository + analysisRepo linguistics.AnalysisRepository + translationRepo domain.TranslationRepository + workRepo domain.WorkRepository + sentimentProvider linguistics.SentimentProvider +} + +func NewService(repo domain.AnalyticsRepository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service { + return &service{ + repo: repo, + analysisRepo: analysisRepo, + translationRepo: translationRepo, + workRepo: workRepo, + sentimentProvider: sentimentProvider, + } +} + +func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "views", 1) +} + +func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1) +} + +func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1) +} + +func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1) +} + +func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1) +} + +func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1) +} + +func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1) +} + +func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1) +} + +func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1) +} + +func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1) +} + +func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + return s.repo.GetOrCreateWorkStats(ctx, workID) +} + +func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + return s.repo.GetOrCreateTranslationStats(ctx, translationID) +} + +func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + textMetadata, _, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + return err + } + + if textMetadata == nil { + return errors.New("text metadata not found") + } + + readingTime := 0 + if textMetadata.WordCount > 0 { + readingTime = (textMetadata.WordCount + 199) / 200 // Ceil division + } + + stats.ReadingTime = readingTime + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + _, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + log.LogWarn("could not get readability score for work", log.F("workID", workID), log.F("error", err)) + return nil + } + + if readabilityScore == nil { + return errors.New("readability score not found") + } + + stats.Complexity = readabilityScore.Score + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + _, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + log.LogWarn("could not get language analysis for work", log.F("workID", workID), log.F("error", err)) + return nil + } + + if languageAnalysis == nil { + return errors.New("language analysis not found") + } + + sentiment, ok := languageAnalysis.Analysis["sentiment"].(float64) + if !ok { + return errors.New("sentiment score not found in language analysis") + } + + stats.Sentiment = sentiment + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error { + stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) + if err != nil { + return err + } + + translation, err := s.translationRepo.GetByID(ctx, translationID) + if err != nil { + return err + } + + if translation == nil { + return errors.New("translation not found") + } + + wordCount := len(strings.Fields(translation.Content)) + readingTime := 0 + if wordCount > 0 { + readingTime = (wordCount + 199) / 200 // Ceil division + } + + stats.ReadingTime = readingTime + + return s.repo.UpdateTranslationStats(ctx, translationID, *stats) +} + +func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error { + stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) + if err != nil { + return err + } + + translation, err := s.translationRepo.GetByID(ctx, translationID) + if err != nil { + return err + } + + if translation == nil { + return errors.New("translation not found") + } + + sentiment, err := s.sentimentProvider.Score(translation.Content, translation.Language) + if err != nil { + return err + } + + stats.Sentiment = sentiment + + return s.repo.UpdateTranslationStats(ctx, translationID, *stats) +} + +func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error { + today := time.Now().UTC().Truncate(24 * time.Hour) + engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today) + if err != nil { + return err + } + + switch eventType { + case "work_read": + engagement.WorksRead++ + case "comment_made": + engagement.CommentsMade++ + case "like_given": + engagement.LikesGiven++ + case "bookmark_made": + engagement.BookmarksMade++ + case "translation_made": + engagement.TranslationsMade++ + default: + return errors.New("invalid engagement event type") + } + + return s.repo.UpdateUserEngagement(ctx, engagement) +} + +func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { + return s.repo.GetTrendingWorks(ctx, timePeriod, limit) +} + +func (s *service) UpdateTrending(ctx context.Context) error { + log.LogInfo("Updating trending works") + + works, err := s.workRepo.ListAll(ctx) + if err != nil { + return fmt.Errorf("failed to list works: %w", err) + } + + trendingWorks := make([]*domain.Trending, 0, len(works)) + for _, work := range works { + stats, err := s.repo.GetOrCreateWorkStats(ctx, work.ID) + if err != nil { + log.LogWarn("failed to get work stats", log.F("workID", work.ID), log.F("error", err)) + continue + } + + score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3) + + trendingWorks = append(trendingWorks, &domain.Trending{ + EntityType: "Work", + EntityID: work.ID, + Score: score, + TimePeriod: "daily", // Hardcoded for now + Date: time.Now().UTC(), + }) + } + + // Sort by score + sort.Slice(trendingWorks, func(i, j int) bool { + return trendingWorks[i].Score > trendingWorks[j].Score + }) + + // Get top 10 + if len(trendingWorks) > 10 { + trendingWorks = trendingWorks[:10] + } + + // Set ranks + for i := range trendingWorks { + trendingWorks[i].Rank = i + 1 + } + + return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks) +} diff --git a/internal/app/analytics/service_test.go b/internal/app/analytics/service_test.go new file mode 100644 index 0000000..08f0963 --- /dev/null +++ b/internal/app/analytics/service_test.go @@ -0,0 +1,260 @@ +package analytics_test + +import ( + "context" + "strings" + "testing" + "tercul/internal/app/analytics" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/jobs/linguistics" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type AnalyticsServiceTestSuite struct { + testutil.IntegrationTestSuite + service analytics.Service +} + +func (s *AnalyticsServiceTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + analyticsRepo := sql.NewAnalyticsRepository(s.DB) + analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB) + translationRepo := sql.NewTranslationRepository(s.DB) + workRepo := sql.NewWorkRepository(s.DB) + sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider() + s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, sentimentProvider) +} + +func (s *AnalyticsServiceTestSuite) SetupTest() { + s.IntegrationTestSuite.SetupTest() + s.DB.Exec("DELETE FROM trendings") +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkViews() { + s.Run("should increment the view count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkViews(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Views) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkLikes() { + s.Run("should increment the like count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkLikes(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Likes) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkComments() { + s.Run("should increment the comment count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkComments(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Comments) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkBookmarks() { + s.Run("should increment the bookmark count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkBookmarks(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Bookmarks) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkShares() { + s.Run("should increment the share count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkShares(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Shares) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkTranslationCount() { + s.Run("should increment the translation count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkTranslationCount(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.TranslationCount) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkReadingTime() { + s.Run("should update the reading time for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.ReadabilityScore{WorkID: work.ID}) + s.DB.Create(&domain.LanguageAnalysis{WorkID: work.ID, Analysis: domain.JSONB{}}) + textMetadata := &domain.TextMetadata{ + WorkID: work.ID, + WordCount: 1000, + } + s.DB.Create(textMetadata) + + // Act + err := s.service.UpdateWorkReadingTime(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(5, stats.ReadingTime) // 1000 words / 200 wpm = 5 minutes + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTranslationReadingTime() { + s.Run("should update the reading time for a translation", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation := s.CreateTestTranslation(work.ID, "es", strings.Repeat("Contenido de prueba con quinientas palabras. ", 100)) + + // Act + err := s.service.UpdateTranslationReadingTime(context.Background(), translation.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateTranslationStats(context.Background(), translation.ID) + s.Require().NoError(err) + s.Equal(3, stats.ReadingTime) // 500 words / 200 wpm = 2.5 -> 3 minutes + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkComplexity() { + s.Run("should update the complexity for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.TextMetadata{WorkID: work.ID}) + s.DB.Create(&domain.LanguageAnalysis{WorkID: work.ID, Analysis: domain.JSONB{}}) + readabilityScore := &domain.ReadabilityScore{ + WorkID: work.ID, + Score: 12.34, + } + s.DB.Create(readabilityScore) + + // Act + err := s.service.UpdateWorkComplexity(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(12.34, stats.Complexity) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkSentiment() { + s.Run("should update the sentiment for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.TextMetadata{WorkID: work.ID}) + s.DB.Create(&domain.ReadabilityScore{WorkID: work.ID}) + languageAnalysis := &domain.LanguageAnalysis{ + WorkID: work.ID, + Analysis: domain.JSONB{ + "sentiment": 0.5678, + }, + } + s.DB.Create(languageAnalysis) + + // Act + err := s.service.UpdateWorkSentiment(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(0.5678, stats.Sentiment) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTranslationSentiment() { + s.Run("should update the sentiment for a translation", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation := s.CreateTestTranslation(work.ID, "en", "This is a wonderfully positive and uplifting sentence.") + + // Act + err := s.service.UpdateTranslationSentiment(context.Background(), translation.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateTranslationStats(context.Background(), translation.ID) + s.Require().NoError(err) + s.True(stats.Sentiment > 0.5) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTrending() { + s.Run("should update the trending works", func() { + // Arrange + work1 := s.CreateTestWork("Work 1", "en", "content") + work2 := s.CreateTestWork("Work 2", "en", "content") + s.DB.Create(&domain.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1}) + s.DB.Create(&domain.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10}) + + // Act + err := s.service.UpdateTrending(context.Background()) + s.Require().NoError(err) + + // Assert + var trendingWorks []*domain.Trending + s.DB.Order("rank asc").Find(&trendingWorks) + s.Require().Len(trendingWorks, 2) + s.Equal(work2.ID, trendingWorks[0].EntityID) + s.Equal(work1.ID, trendingWorks[1].EntityID) + }) +} + +func TestAnalyticsService(t *testing.T) { + suite.Run(t, new(AnalyticsServiceTestSuite)) +} diff --git a/internal/app/app.go b/internal/app/app.go index ae0a7bc..030df94 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,29 +1,68 @@ package app import ( - "tercul/internal/app/auth" - "tercul/internal/app/copyright" + "tercul/internal/app/author" + "tercul/internal/app/bookmark" + "tercul/internal/app/category" + "tercul/internal/app/collection" + "tercul/internal/app/comment" + "tercul/internal/app/like" + "tercul/internal/app/tag" + "tercul/internal/app/translation" + "tercul/internal/app/user" "tercul/internal/app/localization" - "tercul/internal/app/search" + "tercul/internal/app/auth" "tercul/internal/app/work" "tercul/internal/domain" + "tercul/internal/data/sql" + platform_auth "tercul/internal/platform/auth" ) // Application is a container for all the application-layer services. -// It's used for dependency injection into the presentation layer (e.g., GraphQL resolvers). type Application struct { - AuthCommands *auth.AuthCommands - AuthQueries *auth.AuthQueries - CopyrightCommands *copyright.CopyrightCommands - CopyrightQueries *copyright.CopyrightQueries - Localization localization.Service - Search search.IndexService - WorkCommands *work.WorkCommands - WorkQueries *work.WorkQueries - - // Repositories - to be refactored into app services - AuthorRepo domain.AuthorRepository - UserRepo domain.UserRepository - TagRepo domain.TagRepository - CategoryRepo domain.CategoryRepository + Author *author.Service + Bookmark *bookmark.Service + Category *category.Service + Collection *collection.Service + Comment *comment.Service + Like *like.Service + Tag *tag.Service + Translation *translation.Service + User *user.Service + Localization *localization.Service + Auth *auth.Service + Work *work.Service + Repos *sql.Repositories +} + +func NewApplication(repos *sql.Repositories, searchClient domain.SearchClient) *Application { + jwtManager := platform_auth.NewJWTManager() + authorService := author.NewService(repos.Author) + bookmarkService := bookmark.NewService(repos.Bookmark) + categoryService := category.NewService(repos.Category) + collectionService := collection.NewService(repos.Collection) + commentService := comment.NewService(repos.Comment) + likeService := like.NewService(repos.Like) + tagService := tag.NewService(repos.Tag) + translationService := translation.NewService(repos.Translation) + userService := user.NewService(repos.User) + localizationService := localization.NewService(repos.Localization) + authService := auth.NewService(repos.User, jwtManager) + workService := work.NewService(repos.Work, searchClient) + + return &Application{ + Author: authorService, + Bookmark: bookmarkService, + Category: categoryService, + Collection: collectionService, + Comment: commentService, + Like: likeService, + Tag: tagService, + Translation: translationService, + User: userService, + Localization: localizationService, + Auth: authService, + Work: workService, + Repos: repos, + } } diff --git a/internal/app/application_builder.go b/internal/app/application_builder.go deleted file mode 100644 index bb28709..0000000 --- a/internal/app/application_builder.go +++ /dev/null @@ -1,194 +0,0 @@ -package app - -import ( - "tercul/internal/app/auth" - "tercul/internal/app/copyright" - "tercul/internal/app/localization" - "tercul/internal/app/search" - "tercul/internal/app/work" - "tercul/internal/data/sql" - "tercul/internal/platform/cache" - "tercul/internal/platform/config" - "tercul/internal/platform/db" - "tercul/internal/platform/log" - auth_platform "tercul/internal/platform/auth" - "tercul/internal/jobs/linguistics" - - "github.com/hibiken/asynq" - "github.com/weaviate/weaviate-go-client/v5/weaviate" - "gorm.io/gorm" -) - -// ApplicationBuilder handles the initialization of all application components -type ApplicationBuilder struct { - dbConn *gorm.DB - redisCache cache.Cache - weaviateClient *weaviate.Client - asynqClient *asynq.Client - App *Application - linguistics *linguistics.LinguisticsFactory -} - -// NewApplicationBuilder creates a new ApplicationBuilder -func NewApplicationBuilder() *ApplicationBuilder { - return &ApplicationBuilder{} -} - -// BuildDatabase initializes the database connection -func (b *ApplicationBuilder) BuildDatabase() error { - log.LogInfo("Initializing database connection") - dbConn, err := db.InitDB() - if err != nil { - log.LogFatal("Failed to initialize database", log.F("error", err)) - return err - } - b.dbConn = dbConn - log.LogInfo("Database initialized successfully") - return nil -} - -// BuildCache initializes the Redis cache -func (b *ApplicationBuilder) BuildCache() error { - log.LogInfo("Initializing Redis cache") - redisCache, err := cache.NewDefaultRedisCache() - if err != nil { - log.LogWarn("Failed to initialize Redis cache, continuing without caching", log.F("error", err)) - } else { - b.redisCache = redisCache - log.LogInfo("Redis cache initialized successfully") - } - return nil -} - -// BuildWeaviate initializes the Weaviate client -func (b *ApplicationBuilder) BuildWeaviate() error { - log.LogInfo("Connecting to Weaviate", log.F("host", config.Cfg.WeaviateHost)) - wClient, err := weaviate.NewClient(weaviate.Config{ - Scheme: config.Cfg.WeaviateScheme, - Host: config.Cfg.WeaviateHost, - }) - if err != nil { - log.LogFatal("Failed to create Weaviate client", log.F("error", err)) - return err - } - b.weaviateClient = wClient - log.LogInfo("Weaviate client initialized successfully") - return nil -} - -// BuildBackgroundJobs initializes Asynq for background job processing -func (b *ApplicationBuilder) BuildBackgroundJobs() error { - log.LogInfo("Setting up background job processing") - redisOpt := asynq.RedisClientOpt{ - Addr: config.Cfg.RedisAddr, - Password: config.Cfg.RedisPassword, - DB: config.Cfg.RedisDB, - } - b.asynqClient = asynq.NewClient(redisOpt) - log.LogInfo("Background job client initialized successfully") - return nil -} - -// BuildLinguistics initializes the linguistics components -func (b *ApplicationBuilder) BuildLinguistics() error { - log.LogInfo("Initializing linguistic analyzer") - b.linguistics = linguistics.NewLinguisticsFactory(b.dbConn, b.redisCache, 4, true) - log.LogInfo("Linguistics components initialized successfully") - return nil -} - -// BuildApplication initializes all application services -func (b *ApplicationBuilder) BuildApplication() error { - log.LogInfo("Initializing application layer") - - // Initialize repositories - // Note: This is a simplified wiring. In a real app, you might have more complex dependencies. - workRepo := sql.NewWorkRepository(b.dbConn) - userRepo := sql.NewUserRepository(b.dbConn) - // I need to add all the other repos here. For now, I'll just add the ones I need for the services. - translationRepo := sql.NewTranslationRepository(b.dbConn) - copyrightRepo := sql.NewCopyrightRepository(b.dbConn) - authorRepo := sql.NewAuthorRepository(b.dbConn) - tagRepo := sql.NewTagRepository(b.dbConn) - categoryRepo := sql.NewCategoryRepository(b.dbConn) - - - // Initialize application services - workCommands := work.NewWorkCommands(workRepo, b.linguistics.GetAnalyzer()) - workQueries := work.NewWorkQueries(workRepo) - - jwtManager := auth_platform.NewJWTManager() - authCommands := auth.NewAuthCommands(userRepo, jwtManager) - authQueries := auth.NewAuthQueries(userRepo, jwtManager) - - copyrightCommands := copyright.NewCopyrightCommands(copyrightRepo) - copyrightQueries := copyright.NewCopyrightQueries(copyrightRepo) - - localizationService := localization.NewService(translationRepo) - - searchService := search.NewIndexService(localizationService, translationRepo) - - b.App = &Application{ - WorkCommands: workCommands, - WorkQueries: workQueries, - AuthCommands: authCommands, - AuthQueries: authQueries, - CopyrightCommands: copyrightCommands, - CopyrightQueries: copyrightQueries, - Localization: localizationService, - Search: searchService, - AuthorRepo: authorRepo, - UserRepo: userRepo, - TagRepo: tagRepo, - CategoryRepo: categoryRepo, - } - - log.LogInfo("Application layer initialized successfully") - return nil -} - -// Build initializes all components in the correct order -func (b *ApplicationBuilder) Build() error { - if err := b.BuildDatabase(); err != nil { return err } - if err := b.BuildCache(); err != nil { return err } - if err := b.BuildWeaviate(); err != nil { return err } - if err := b.BuildBackgroundJobs(); err != nil { return err } - if err := b.BuildLinguistics(); err != nil { return err } - if err := b.BuildApplication(); err != nil { return err } - log.LogInfo("Application builder completed successfully") - return nil -} - -// GetApplication returns the application container -func (b *ApplicationBuilder) GetApplication() *Application { - return b.App -} - -// GetDB returns the database connection -func (b *ApplicationBuilder) GetDB() *gorm.DB { - return b.dbConn -} - -// GetAsynq returns the Asynq client -func (b *ApplicationBuilder) GetAsynq() *asynq.Client { - return b.asynqClient -} - -// GetLinguisticsFactory returns the linguistics factory -func (b *ApplicationBuilder) GetLinguisticsFactory() *linguistics.LinguisticsFactory { - return b.linguistics -} - -// Close closes all resources -func (b *ApplicationBuilder) Close() error { - if b.asynqClient != nil { - b.asynqClient.Close() - } - if b.dbConn != nil { - sqlDB, err := b.dbConn.DB() - if err == nil { - sqlDB.Close() - } - } - return nil -} diff --git a/internal/app/auth/commands.go b/internal/app/auth/commands.go index aa8a089..d1c1126 100644 --- a/internal/app/auth/commands.go +++ b/internal/app/auth/commands.go @@ -44,11 +44,11 @@ type AuthResponse struct { // AuthCommands contains the command handlers for authentication. type AuthCommands struct { userRepo domain.UserRepository - jwtManager *auth.JWTManager + jwtManager auth.JWTManagement } // NewAuthCommands creates a new AuthCommands handler. -func NewAuthCommands(userRepo domain.UserRepository, jwtManager *auth.JWTManager) *AuthCommands { +func NewAuthCommands(userRepo domain.UserRepository, jwtManager auth.JWTManagement) *AuthCommands { return &AuthCommands{ userRepo: userRepo, jwtManager: jwtManager, @@ -58,11 +58,12 @@ func NewAuthCommands(userRepo domain.UserRepository, jwtManager *auth.JWTManager // Login authenticates a user and returns a JWT token func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthResponse, error) { if err := validateLoginInput(input); err != nil { - log.LogWarn("Login failed - invalid input", log.F("email", input.Email), log.F("error", err)) + log.LogWarn("Login validation failed", log.F("email", input.Email), log.F("error", err)) return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) } email := strings.TrimSpace(input.Email) + log.LogDebug("Attempting to log in user", log.F("email", email)) user, err := c.userRepo.FindByEmail(ctx, email) if err != nil { log.LogWarn("Login failed - user not found", log.F("email", email)) @@ -89,25 +90,27 @@ func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthRespon user.LastLoginAt = &now if err := c.userRepo.Update(ctx, user); err != nil { log.LogWarn("Failed to update last login time", log.F("user_id", user.ID), log.F("error", err)) + // Do not fail the login if this update fails } log.LogInfo("User logged in successfully", log.F("user_id", user.ID), log.F("email", email)) return &AuthResponse{ Token: token, User: user, - ExpiresAt: time.Now().Add(24 * time.Hour), + ExpiresAt: time.Now().Add(24 * time.Hour), // This should be configurable }, nil } // Register creates a new user account func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) { if err := validateRegisterInput(input); err != nil { - log.LogWarn("Registration failed - invalid input", log.F("email", input.Email), log.F("error", err)) + log.LogWarn("Registration validation failed", log.F("email", input.Email), log.F("error", err)) return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) } email := strings.TrimSpace(input.Email) username := strings.TrimSpace(input.Username) + log.LogDebug("Attempting to register new user", log.F("email", email), log.F("username", username)) existingUser, _ := c.userRepo.FindByEmail(ctx, email) if existingUser != nil { @@ -130,7 +133,7 @@ func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*Auth DisplayName: fmt.Sprintf("%s %s", strings.TrimSpace(input.FirstName), strings.TrimSpace(input.LastName)), Role: domain.UserRoleReader, Active: true, - Verified: false, + Verified: false, // Should be false until email verification } if err := c.userRepo.Create(ctx, user); err != nil { @@ -148,7 +151,7 @@ func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*Auth return &AuthResponse{ Token: token, User: user, - ExpiresAt: time.Now().Add(24 * time.Hour), + ExpiresAt: time.Now().Add(24 * time.Hour), // This should be configurable }, nil } diff --git a/internal/app/auth/commands_test.go b/internal/app/auth/commands_test.go new file mode 100644 index 0000000..9d0b8b0 --- /dev/null +++ b/internal/app/auth/commands_test.go @@ -0,0 +1,286 @@ +package auth + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "testing" +) + + +type AuthCommandsSuite struct { + suite.Suite + userRepo *mockUserRepository + jwtManager *mockJWTManager + commands *AuthCommands +} + +func (s *AuthCommandsSuite) SetupTest() { + s.userRepo = newMockUserRepository() + s.jwtManager = &mockJWTManager{} + s.commands = NewAuthCommands(s.userRepo, s.jwtManager) +} + +func TestAuthCommandsSuite(t *testing.T) { + suite.Run(t, new(AuthCommandsSuite)) +} + +func (s *AuthCommandsSuite) TestLogin_Success() { + user := domain.User{ + Email: "test@example.com", + Password: "password", + Active: true, + } + s.userRepo.Create(context.Background(), &user) + + input := LoginInput{Email: "test@example.com", Password: "password"} + resp, err := s.commands.Login(context.Background(), input) + + assert.NoError(s.T(), err) + assert.NotNil(s.T(), resp) + assert.Equal(s.T(), "test-token", resp.Token) + assert.Equal(s.T(), user.ID, resp.User.ID) +} + +func (s *AuthCommandsSuite) TestLogin_InvalidInput() { + input := LoginInput{Email: "invalid-email", Password: "short"} + resp, err := s.commands.Login(context.Background(), input) + assert.Error(s.T(), err) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestValidateLoginInput_EmptyEmail() { + input := LoginInput{Email: "", Password: "password"} + err := validateLoginInput(input) + assert.Error(s.T(), err) +} + +func (s *AuthCommandsSuite) TestValidateLoginInput_ShortPassword() { + input := LoginInput{Email: "test@example.com", Password: "short"} + err := validateLoginInput(input) + assert.Error(s.T(), err) +} + +func (s *AuthCommandsSuite) TestValidateRegisterInput_ShortPassword() { + input := RegisterInput{Email: "test@example.com", Password: "short"} + err := validateRegisterInput(input) + assert.Error(s.T(), err) +} + +func (s *AuthCommandsSuite) TestValidateRegisterInput_ShortUsername() { + input := RegisterInput{Username: "a", Email: "test@example.com", Password: "password"} + err := validateRegisterInput(input) + assert.Error(s.T(), err) +} + +func (s *AuthCommandsSuite) TestValidateRegisterInput_LongUsername() { + input := RegisterInput{Username: "a51characterusernameisdefinitelytoolongforthisvalidation", Email: "test@example.com", Password: "password"} + err := validateRegisterInput(input) + assert.Error(s.T(), err) +} + +func (s *AuthCommandsSuite) TestLogin_SuccessUpdate() { + user := domain.User{ + Email: "test@example.com", + Password: "password", + Active: true, + } + s.userRepo.Create(context.Background(), &user) + s.userRepo.updateFunc = func(ctx context.Context, user *domain.User) error { + return nil + } + + input := LoginInput{Email: "test@example.com", Password: "password"} + resp, err := s.commands.Login(context.Background(), input) + + assert.NoError(s.T(), err) + assert.NotNil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestRegister_InvalidEmail() { + input := RegisterInput{ + Username: "newuser", + Email: "invalid-email", + Password: "password", + FirstName: "New", + LastName: "User", + } + resp, err := s.commands.Register(context.Background(), input) + assert.Error(s.T(), err) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestLogin_UpdateUserError() { + user := domain.User{ + Email: "test@example.com", + Password: "password", + Active: true, + } + s.userRepo.Create(context.Background(), &user) + s.userRepo.updateFunc = func(ctx context.Context, user *domain.User) error { + return errors.New("update error") + } + + input := LoginInput{Email: "test@example.com", Password: "password"} + resp, err := s.commands.Login(context.Background(), input) + + assert.NoError(s.T(), err) + assert.NotNil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestRegister_InvalidUsername() { + input := RegisterInput{ + Username: "invalid username", + Email: "new@example.com", + Password: "password", + FirstName: "New", + LastName: "User", + } + resp, err := s.commands.Register(context.Background(), input) + assert.Error(s.T(), err) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestLogin_UserNotFound() { + input := LoginInput{Email: "notfound@example.com", Password: "password"} + resp, err := s.commands.Login(context.Background(), input) + assert.ErrorIs(s.T(), err, ErrInvalidCredentials) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestLogin_InactiveUser() { + user := domain.User{ + Email: "inactive@example.com", + Password: "password", + Active: false, + } + s.userRepo.Create(context.Background(), &user) + + input := LoginInput{Email: "inactive@example.com", Password: "password"} + resp, err := s.commands.Login(context.Background(), input) + assert.ErrorIs(s.T(), err, ErrInvalidCredentials) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestLogin_InvalidPassword() { + user := domain.User{ + Email: "test@example.com", + Password: "password", + Active: true, + } + s.userRepo.Create(context.Background(), &user) + + input := LoginInput{Email: "test@example.com", Password: "wrong-password"} + resp, err := s.commands.Login(context.Background(), input) + assert.ErrorIs(s.T(), err, ErrInvalidCredentials) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestLogin_TokenGenerationError() { + user := domain.User{ + Email: "test@example.com", + Password: "password", + Active: true, + } + s.userRepo.Create(context.Background(), &user) + + s.jwtManager.generateTokenFunc = func(user *domain.User) (string, error) { + return "", errors.New("jwt error") + } + input := LoginInput{Email: "test@example.com", Password: "password"} + resp, err := s.commands.Login(context.Background(), input) + assert.Error(s.T(), err) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestRegister_Success() { + input := RegisterInput{ + Username: "newuser", + Email: "new@example.com", + Password: "password", + FirstName: "New", + LastName: "User", + } + resp, err := s.commands.Register(context.Background(), input) + assert.NoError(s.T(), err) + assert.NotNil(s.T(), resp) + assert.Equal(s.T(), "test-token", resp.Token) + assert.Equal(s.T(), "newuser", resp.User.Username) +} + +func (s *AuthCommandsSuite) TestRegister_InvalidInput() { + input := RegisterInput{Email: "invalid"} + resp, err := s.commands.Register(context.Background(), input) + assert.Error(s.T(), err) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestRegister_EmailExists() { + user := domain.User{ + Email: "exists@example.com", + } + s.userRepo.Create(context.Background(), &user) + + input := RegisterInput{ + Username: "newuser", + Email: "exists@example.com", + Password: "password", + FirstName: "New", + LastName: "User", + } + resp, err := s.commands.Register(context.Background(), input) + assert.ErrorIs(s.T(), err, ErrUserAlreadyExists) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestRegister_UsernameExists() { + user := domain.User{ + Username: "exists", + } + s.userRepo.Create(context.Background(), &user) + + input := RegisterInput{ + Username: "exists", + Email: "new@example.com", + Password: "password", + FirstName: "New", + LastName: "User", + } + resp, err := s.commands.Register(context.Background(), input) + assert.ErrorIs(s.T(), err, ErrUserAlreadyExists) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestRegister_CreateUserError() { + s.userRepo.createFunc = func(ctx context.Context, user *domain.User) error { + return errors.New("db error") + } + input := RegisterInput{ + Username: "newuser", + Email: "new@example.com", + Password: "password", + FirstName: "New", + LastName: "User", + } + resp, err := s.commands.Register(context.Background(), input) + assert.Error(s.T(), err) + assert.Nil(s.T(), resp) +} + +func (s *AuthCommandsSuite) TestRegister_TokenGenerationError() { + s.jwtManager.generateTokenFunc = func(user *domain.User) (string, error) { + return "", errors.New("jwt error") + } + input := RegisterInput{ + Username: "newuser", + Email: "new@example.com", + Password: "password", + FirstName: "New", + LastName: "User", + } + resp, err := s.commands.Register(context.Background(), input) + assert.Error(s.T(), err) + assert.Nil(s.T(), resp) +} diff --git a/internal/app/auth/main_test.go b/internal/app/auth/main_test.go new file mode 100644 index 0000000..d1314c1 --- /dev/null +++ b/internal/app/auth/main_test.go @@ -0,0 +1,138 @@ +package auth + +import ( + "context" + "errors" + "gorm.io/gorm" + "tercul/internal/domain" + "tercul/internal/platform/auth" +) + +// mockUserRepository is a local mock for the UserRepository interface. +type mockUserRepository struct { + users map[uint]domain.User + findByEmailFunc func(ctx context.Context, email string) (*domain.User, error) + findByUsernameFunc func(ctx context.Context, username string) (*domain.User, error) + createFunc func(ctx context.Context, user *domain.User) error + updateFunc func(ctx context.Context, user *domain.User) error + getByIDFunc func(ctx context.Context, id uint) (*domain.User, error) +} + +func newMockUserRepository() *mockUserRepository { + return &mockUserRepository{users: make(map[uint]domain.User)} +} + +func (m *mockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + if m.findByEmailFunc != nil { + return m.findByEmailFunc(ctx, email) + } + for _, u := range m.users { + if u.Email == email { + return &u, nil + } + } + return nil, errors.New("user not found") +} + +func (m *mockUserRepository) FindByUsername(ctx context.Context, username string) (*domain.User, error) { + if m.findByUsernameFunc != nil { + return m.findByUsernameFunc(ctx, username) + } + for _, u := range m.users { + if u.Username == username { + return &u, nil + } + } + return nil, errors.New("user not found") +} + +func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error { + if m.createFunc != nil { + return m.createFunc(ctx, user) + } + // Simulate the BeforeSave hook for password hashing + if err := user.BeforeSave(nil); err != nil { + return err + } + user.ID = uint(len(m.users) + 1) + m.users[user.ID] = *user + return nil +} + +func (m *mockUserRepository) Update(ctx context.Context, user *domain.User) error { + if m.updateFunc != nil { + return m.updateFunc(ctx, user) + } + if _, ok := m.users[user.ID]; ok { + m.users[user.ID] = *user + return nil + } + return errors.New("user not found") +} + +func (m *mockUserRepository) GetByID(ctx context.Context, id uint) (*domain.User, error) { + if m.getByIDFunc != nil { + return m.getByIDFunc(ctx, id) + } + if user, ok := m.users[id]; ok { + return &user, nil + } + return nil, errors.New("user not found") +} + +// Implement the rest of the UserRepository interface with empty methods. +func (m *mockUserRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.User], error) { + return nil, nil +} +func (m *mockUserRepository) ListAll(ctx context.Context) ([]domain.User, error) { return nil, nil } +func (m *mockUserRepository) Count(ctx context.Context) (int64, error) { return 0, nil } +func (m *mockUserRepository) ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) { + return nil, nil +} +func (m *mockUserRepository) Delete(ctx context.Context, id uint) error { return nil } +func (m *mockUserRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error { + return nil +} +func (m *mockUserRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.User, error) { + return nil, nil +} +func (m *mockUserRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error { + return nil +} +func (m *mockUserRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil } +func (m *mockUserRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.User, error) { + return nil, nil +} +func (m *mockUserRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + return 0, nil +} +func (m *mockUserRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.User, error) { + return nil, nil +} +func (m *mockUserRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.User, error) { + return nil, nil +} +func (m *mockUserRepository) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } +func (m *mockUserRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } +func (m *mockUserRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return nil +} + +// mockJWTManager is a local mock for the JWTManager. +type mockJWTManager struct { + generateTokenFunc func(user *domain.User) (string, error) + validateTokenFunc func(tokenString string) (*auth.Claims, error) +} + +func (m *mockJWTManager) GenerateToken(user *domain.User) (string, error) { + if m.generateTokenFunc != nil { + return m.generateTokenFunc(user) + } + return "test-token", nil +} +func (m *mockJWTManager) ValidateToken(tokenString string) (*auth.Claims, error) { + if m.validateTokenFunc != nil { + return m.validateTokenFunc(tokenString) + } + return &auth.Claims{UserID: 1}, nil +} diff --git a/internal/app/auth/queries.go b/internal/app/auth/queries.go index 68af553..19d39cf 100644 --- a/internal/app/auth/queries.go +++ b/internal/app/auth/queries.go @@ -16,11 +16,11 @@ var ( // AuthQueries contains the query handlers for authentication. type AuthQueries struct { userRepo domain.UserRepository - jwtManager *auth.JWTManager + jwtManager auth.JWTManagement } // NewAuthQueries creates a new AuthQueries handler. -func NewAuthQueries(userRepo domain.UserRepository, jwtManager *auth.JWTManager) *AuthQueries { +func NewAuthQueries(userRepo domain.UserRepository, jwtManager auth.JWTManagement) *AuthQueries { return &AuthQueries{ userRepo: userRepo, jwtManager: jwtManager, @@ -32,12 +32,14 @@ func (q *AuthQueries) GetUserFromContext(ctx context.Context) (*domain.User, err if ctx == nil { return nil, ErrContextRequired } + log.LogDebug("Attempting to get user from context") claims, err := auth.RequireAuth(ctx) if err != nil { log.LogWarn("Failed to get user from context - authentication required", log.F("error", err)) return nil, err } + log.LogDebug("Claims found in context", log.F("user_id", claims.UserID)) user, err := q.userRepo.GetByID(ctx, claims.UserID) if err != nil { @@ -50,6 +52,7 @@ func (q *AuthQueries) GetUserFromContext(ctx context.Context) (*domain.User, err return nil, ErrInvalidCredentials } + log.LogDebug("User retrieved from context successfully", log.F("user_id", user.ID)) return user, nil } @@ -63,12 +66,14 @@ func (q *AuthQueries) ValidateToken(ctx context.Context, tokenString string) (*d log.LogWarn("Token validation failed - empty token") return nil, auth.ErrMissingToken } + log.LogDebug("Attempting to validate token") claims, err := q.jwtManager.ValidateToken(tokenString) if err != nil { log.LogWarn("Token validation failed - invalid token", log.F("error", err)) return nil, err } + log.LogDebug("Token claims validated", log.F("user_id", claims.UserID)) user, err := q.userRepo.GetByID(ctx, claims.UserID) if err != nil { diff --git a/internal/app/auth/queries_test.go b/internal/app/auth/queries_test.go new file mode 100644 index 0000000..ac2c4e2 --- /dev/null +++ b/internal/app/auth/queries_test.go @@ -0,0 +1,134 @@ +package auth + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "tercul/internal/platform/auth" + "testing" +) + +type AuthQueriesSuite struct { + suite.Suite + userRepo *mockUserRepository + jwtManager *mockJWTManager + queries *AuthQueries +} + +func (s *AuthQueriesSuite) SetupTest() { + s.userRepo = newMockUserRepository() + s.jwtManager = &mockJWTManager{} + s.queries = NewAuthQueries(s.userRepo, s.jwtManager) +} + +func TestAuthQueriesSuite(t *testing.T) { + suite.Run(t, new(AuthQueriesSuite)) +} + +func (s *AuthQueriesSuite) TestGetUserFromContext_Success() { + user := domain.User{Active: true} + user.ID = 1 + s.userRepo.users[1] = user + + ctx := context.WithValue(context.Background(), auth.ClaimsContextKey, &auth.Claims{UserID: 1}) + + retrievedUser, err := s.queries.GetUserFromContext(ctx) + assert.NoError(s.T(), err) + assert.NotNil(s.T(), retrievedUser) + assert.Equal(s.T(), user.ID, retrievedUser.ID) +} + +func (s *AuthQueriesSuite) TestGetUserFromContext_NoClaims() { + retrievedUser, err := s.queries.GetUserFromContext(context.Background()) + assert.Error(s.T(), err) + assert.Nil(s.T(), retrievedUser) +} + +func (s *AuthQueriesSuite) TestGetUserFromContext_UserNotFound() { + ctx := context.WithValue(context.Background(), auth.ClaimsContextKey, &auth.Claims{UserID: 1}) + + retrievedUser, err := s.queries.GetUserFromContext(ctx) + assert.ErrorIs(s.T(), err, ErrUserNotFound) + assert.Nil(s.T(), retrievedUser) +} + +func (s *AuthQueriesSuite) TestGetUserFromContext_InactiveUser() { + user := domain.User{Active: false} + user.ID = 1 + s.userRepo.users[1] = user + + ctx := context.WithValue(context.Background(), auth.ClaimsContextKey, &auth.Claims{UserID: 1}) + + retrievedUser, err := s.queries.GetUserFromContext(ctx) + assert.ErrorIs(s.T(), err, ErrInvalidCredentials) + assert.Nil(s.T(), retrievedUser) +} + +func (s *AuthQueriesSuite) TestGetUserFromContext_NilContext() { + user, err := s.queries.GetUserFromContext(nil) + assert.ErrorIs(s.T(), err, ErrContextRequired) + assert.Nil(s.T(), user) +} + +func (s *AuthQueriesSuite) TestValidateToken_NilContext() { + user, err := s.queries.ValidateToken(nil, "token") + assert.ErrorIs(s.T(), err, ErrContextRequired) + assert.Nil(s.T(), user) +} + +func (s *AuthQueriesSuite) TestValidateToken_Success() { + user := domain.User{Active: true} + user.ID = 1 + s.userRepo.users[1] = user + + s.jwtManager.validateTokenFunc = func(tokenString string) (*auth.Claims, error) { + return &auth.Claims{UserID: 1}, nil + } + + retrievedUser, err := s.queries.ValidateToken(context.Background(), "valid-token") + assert.NoError(s.T(), err) + assert.NotNil(s.T(), retrievedUser) + assert.Equal(s.T(), user.ID, retrievedUser.ID) +} + +func (s *AuthQueriesSuite) TestValidateToken_EmptyToken() { + retrievedUser, err := s.queries.ValidateToken(context.Background(), "") + assert.ErrorIs(s.T(), err, auth.ErrMissingToken) + assert.Nil(s.T(), retrievedUser) +} + +func (s *AuthQueriesSuite) TestValidateToken_InvalidToken() { + s.jwtManager.validateTokenFunc = func(tokenString string) (*auth.Claims, error) { + return nil, errors.New("invalid token") + } + + retrievedUser, err := s.queries.ValidateToken(context.Background(), "invalid-token") + assert.Error(s.T(), err) + assert.Nil(s.T(), retrievedUser) +} + +func (s *AuthQueriesSuite) TestValidateToken_UserNotFound() { + s.jwtManager.validateTokenFunc = func(tokenString string) (*auth.Claims, error) { + return &auth.Claims{UserID: 1}, nil + } + + retrievedUser, err := s.queries.ValidateToken(context.Background(), "valid-token") + assert.ErrorIs(s.T(), err, ErrUserNotFound) + assert.Nil(s.T(), retrievedUser) +} + +func (s *AuthQueriesSuite) TestValidateToken_InactiveUser() { + user := domain.User{Active: false} + user.ID = 1 + s.userRepo.users[1] = user + + s.jwtManager.validateTokenFunc = func(tokenString string) (*auth.Claims, error) { + return &auth.Claims{UserID: 1}, nil + } + + retrievedUser, err := s.queries.ValidateToken(context.Background(), "valid-token") + assert.ErrorIs(s.T(), err, ErrInvalidCredentials) + assert.Nil(s.T(), retrievedUser) +} diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go new file mode 100644 index 0000000..b1dc019 --- /dev/null +++ b/internal/app/auth/service.go @@ -0,0 +1,20 @@ +package auth + +import ( + "tercul/internal/domain" + "tercul/internal/platform/auth" +) + +// Service is the application service for the auth aggregate. +type Service struct { + Commands *AuthCommands + Queries *AuthQueries +} + +// NewService creates a new auth Service. +func NewService(userRepo domain.UserRepository, jwtManager auth.JWTManagement) *Service { + return &Service{ + Commands: NewAuthCommands(userRepo, jwtManager), + Queries: NewAuthQueries(userRepo, jwtManager), + } +} diff --git a/internal/app/author/commands.go b/internal/app/author/commands.go new file mode 100644 index 0000000..0d32e36 --- /dev/null +++ b/internal/app/author/commands.go @@ -0,0 +1,58 @@ +package author + +import ( + "context" + "tercul/internal/domain" +) + +// AuthorCommands contains the command handlers for the author aggregate. +type AuthorCommands struct { + repo domain.AuthorRepository +} + +// NewAuthorCommands creates a new AuthorCommands handler. +func NewAuthorCommands(repo domain.AuthorRepository) *AuthorCommands { + return &AuthorCommands{repo: repo} +} + +// CreateAuthorInput represents the input for creating a new author. +type CreateAuthorInput struct { + Name string +} + +// CreateAuthor creates a new author. +func (c *AuthorCommands) CreateAuthor(ctx context.Context, input CreateAuthorInput) (*domain.Author, error) { + author := &domain.Author{ + Name: input.Name, + } + err := c.repo.Create(ctx, author) + if err != nil { + return nil, err + } + return author, nil +} + +// UpdateAuthorInput represents the input for updating an existing author. +type UpdateAuthorInput struct { + ID uint + Name string +} + +// UpdateAuthor updates an existing author. +func (c *AuthorCommands) UpdateAuthor(ctx context.Context, input UpdateAuthorInput) (*domain.Author, error) { + author, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + author.Name = input.Name + err = c.repo.Update(ctx, author) + if err != nil { + return nil, err + } + return author, nil +} + +// DeleteAuthor deletes an author by ID. +func (c *AuthorCommands) DeleteAuthor(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/author/queries.go b/internal/app/author/queries.go new file mode 100644 index 0000000..448d356 --- /dev/null +++ b/internal/app/author/queries.go @@ -0,0 +1,34 @@ +package author + +import ( + "context" + "tercul/internal/domain" +) + +// AuthorQueries contains the query handlers for the author aggregate. +type AuthorQueries struct { + repo domain.AuthorRepository +} + +// NewAuthorQueries creates a new AuthorQueries handler. +func NewAuthorQueries(repo domain.AuthorRepository) *AuthorQueries { + return &AuthorQueries{repo: repo} +} + +// Author returns an author by ID. +func (q *AuthorQueries) Author(ctx context.Context, id uint) (*domain.Author, error) { + return q.repo.GetByID(ctx, id) +} + +// Authors returns all authors. +func (q *AuthorQueries) Authors(ctx context.Context) ([]*domain.Author, error) { + authors, err := q.repo.ListAll(ctx) + if err != nil { + return nil, err + } + authorPtrs := make([]*domain.Author, len(authors)) + for i := range authors { + authorPtrs[i] = &authors[i] + } + return authorPtrs, nil +} diff --git a/internal/app/author/service.go b/internal/app/author/service.go new file mode 100644 index 0000000..e7c3b41 --- /dev/null +++ b/internal/app/author/service.go @@ -0,0 +1,17 @@ +package author + +import "tercul/internal/domain" + +// Service is the application service for the author aggregate. +type Service struct { + Commands *AuthorCommands + Queries *AuthorQueries +} + +// NewService creates a new author Service. +func NewService(repo domain.AuthorRepository) *Service { + return &Service{ + Commands: NewAuthorCommands(repo), + Queries: NewAuthorQueries(repo), + } +} diff --git a/internal/app/bookmark/commands.go b/internal/app/bookmark/commands.go new file mode 100644 index 0000000..5471f3c --- /dev/null +++ b/internal/app/bookmark/commands.go @@ -0,0 +1,66 @@ +package bookmark + +import ( + "context" + "tercul/internal/domain" +) + +// BookmarkCommands contains the command handlers for the bookmark aggregate. +type BookmarkCommands struct { + repo domain.BookmarkRepository +} + +// NewBookmarkCommands creates a new BookmarkCommands handler. +func NewBookmarkCommands(repo domain.BookmarkRepository) *BookmarkCommands { + return &BookmarkCommands{repo: repo} +} + +// CreateBookmarkInput represents the input for creating a new bookmark. +type CreateBookmarkInput struct { + Name string + UserID uint + WorkID uint + Notes string +} + +// CreateBookmark creates a new bookmark. +func (c *BookmarkCommands) CreateBookmark(ctx context.Context, input CreateBookmarkInput) (*domain.Bookmark, error) { + bookmark := &domain.Bookmark{ + Name: input.Name, + UserID: input.UserID, + WorkID: input.WorkID, + Notes: input.Notes, + } + err := c.repo.Create(ctx, bookmark) + if err != nil { + return nil, err + } + return bookmark, nil +} + +// UpdateBookmarkInput represents the input for updating an existing bookmark. +type UpdateBookmarkInput struct { + ID uint + Name string + Notes string +} + +// UpdateBookmark updates an existing bookmark. +func (c *BookmarkCommands) UpdateBookmark(ctx context.Context, input UpdateBookmarkInput) (*domain.Bookmark, error) { + bookmark, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + bookmark.Name = input.Name + bookmark.Notes = input.Notes + err = c.repo.Update(ctx, bookmark) + if err != nil { + return nil, err + } + return bookmark, nil +} + +// DeleteBookmark deletes a bookmark by ID. +func (c *BookmarkCommands) DeleteBookmark(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/bookmark/queries.go b/internal/app/bookmark/queries.go new file mode 100644 index 0000000..da53216 --- /dev/null +++ b/internal/app/bookmark/queries.go @@ -0,0 +1,31 @@ +package bookmark + +import ( + "context" + "tercul/internal/domain" +) + +// BookmarkQueries contains the query handlers for the bookmark aggregate. +type BookmarkQueries struct { + repo domain.BookmarkRepository +} + +// NewBookmarkQueries creates a new BookmarkQueries handler. +func NewBookmarkQueries(repo domain.BookmarkRepository) *BookmarkQueries { + return &BookmarkQueries{repo: repo} +} + +// Bookmark returns a bookmark by ID. +func (q *BookmarkQueries) Bookmark(ctx context.Context, id uint) (*domain.Bookmark, error) { + return q.repo.GetByID(ctx, id) +} + +// BookmarksByUserID returns all bookmarks for a user. +func (q *BookmarkQueries) BookmarksByUserID(ctx context.Context, userID uint) ([]domain.Bookmark, error) { + return q.repo.ListByUserID(ctx, userID) +} + +// BookmarksByWorkID returns all bookmarks for a work. +func (q *BookmarkQueries) BookmarksByWorkID(ctx context.Context, workID uint) ([]domain.Bookmark, error) { + return q.repo.ListByWorkID(ctx, workID) +} diff --git a/internal/app/bookmark/service.go b/internal/app/bookmark/service.go new file mode 100644 index 0000000..ccfebfc --- /dev/null +++ b/internal/app/bookmark/service.go @@ -0,0 +1,17 @@ +package bookmark + +import "tercul/internal/domain" + +// Service is the application service for the bookmark aggregate. +type Service struct { + Commands *BookmarkCommands + Queries *BookmarkQueries +} + +// NewService creates a new bookmark Service. +func NewService(repo domain.BookmarkRepository) *Service { + return &Service{ + Commands: NewBookmarkCommands(repo), + Queries: NewBookmarkQueries(repo), + } +} diff --git a/internal/app/category/commands.go b/internal/app/category/commands.go new file mode 100644 index 0000000..27c7b15 --- /dev/null +++ b/internal/app/category/commands.go @@ -0,0 +1,66 @@ +package category + +import ( + "context" + "tercul/internal/domain" +) + +// CategoryCommands contains the command handlers for the category aggregate. +type CategoryCommands struct { + repo domain.CategoryRepository +} + +// NewCategoryCommands creates a new CategoryCommands handler. +func NewCategoryCommands(repo domain.CategoryRepository) *CategoryCommands { + return &CategoryCommands{repo: repo} +} + +// CreateCategoryInput represents the input for creating a new category. +type CreateCategoryInput struct { + Name string + Description string + ParentID *uint +} + +// CreateCategory creates a new category. +func (c *CategoryCommands) CreateCategory(ctx context.Context, input CreateCategoryInput) (*domain.Category, error) { + category := &domain.Category{ + Name: input.Name, + Description: input.Description, + ParentID: input.ParentID, + } + err := c.repo.Create(ctx, category) + if err != nil { + return nil, err + } + return category, nil +} + +// UpdateCategoryInput represents the input for updating an existing category. +type UpdateCategoryInput struct { + ID uint + Name string + Description string + ParentID *uint +} + +// UpdateCategory updates an existing category. +func (c *CategoryCommands) UpdateCategory(ctx context.Context, input UpdateCategoryInput) (*domain.Category, error) { + category, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + category.Name = input.Name + category.Description = input.Description + category.ParentID = input.ParentID + err = c.repo.Update(ctx, category) + if err != nil { + return nil, err + } + return category, nil +} + +// DeleteCategory deletes a category by ID. +func (c *CategoryCommands) DeleteCategory(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/category/queries.go b/internal/app/category/queries.go new file mode 100644 index 0000000..824d893 --- /dev/null +++ b/internal/app/category/queries.go @@ -0,0 +1,41 @@ +package category + +import ( + "context" + "tercul/internal/domain" +) + +// CategoryQueries contains the query handlers for the category aggregate. +type CategoryQueries struct { + repo domain.CategoryRepository +} + +// NewCategoryQueries creates a new CategoryQueries handler. +func NewCategoryQueries(repo domain.CategoryRepository) *CategoryQueries { + return &CategoryQueries{repo: repo} +} + +// Category returns a category by ID. +func (q *CategoryQueries) Category(ctx context.Context, id uint) (*domain.Category, error) { + return q.repo.GetByID(ctx, id) +} + +// CategoryByName returns a category by name. +func (q *CategoryQueries) CategoryByName(ctx context.Context, name string) (*domain.Category, error) { + return q.repo.FindByName(ctx, name) +} + +// CategoriesByWorkID returns all categories for a work. +func (q *CategoryQueries) CategoriesByWorkID(ctx context.Context, workID uint) ([]domain.Category, error) { + return q.repo.ListByWorkID(ctx, workID) +} + +// CategoriesByParentID returns all categories for a parent. +func (q *CategoryQueries) CategoriesByParentID(ctx context.Context, parentID *uint) ([]domain.Category, error) { + return q.repo.ListByParentID(ctx, parentID) +} + +// Categories returns all categories. +func (q *CategoryQueries) Categories(ctx context.Context) ([]domain.Category, error) { + return q.repo.ListAll(ctx) +} diff --git a/internal/app/category/service.go b/internal/app/category/service.go new file mode 100644 index 0000000..3813f5d --- /dev/null +++ b/internal/app/category/service.go @@ -0,0 +1,17 @@ +package category + +import "tercul/internal/domain" + +// Service is the application service for the category aggregate. +type Service struct { + Commands *CategoryCommands + Queries *CategoryQueries +} + +// NewService creates a new category Service. +func NewService(repo domain.CategoryRepository) *Service { + return &Service{ + Commands: NewCategoryCommands(repo), + Queries: NewCategoryQueries(repo), + } +} diff --git a/internal/app/collection/commands.go b/internal/app/collection/commands.go new file mode 100644 index 0000000..99b4f90 --- /dev/null +++ b/internal/app/collection/commands.go @@ -0,0 +1,94 @@ +package collection + +import ( + "context" + "tercul/internal/domain" +) + +// CollectionCommands contains the command handlers for the collection aggregate. +type CollectionCommands struct { + repo domain.CollectionRepository +} + +// NewCollectionCommands creates a new CollectionCommands handler. +func NewCollectionCommands(repo domain.CollectionRepository) *CollectionCommands { + return &CollectionCommands{repo: repo} +} + +// CreateCollectionInput represents the input for creating a new collection. +type CreateCollectionInput struct { + Name string + Description string + UserID uint + IsPublic bool + CoverImageURL string +} + +// CreateCollection creates a new collection. +func (c *CollectionCommands) CreateCollection(ctx context.Context, input CreateCollectionInput) (*domain.Collection, error) { + collection := &domain.Collection{ + Name: input.Name, + Description: input.Description, + UserID: input.UserID, + IsPublic: input.IsPublic, + CoverImageURL: input.CoverImageURL, + } + err := c.repo.Create(ctx, collection) + if err != nil { + return nil, err + } + return collection, nil +} + +// UpdateCollectionInput represents the input for updating an existing collection. +type UpdateCollectionInput struct { + ID uint + Name string + Description string + IsPublic bool + CoverImageURL string +} + +// UpdateCollection updates an existing collection. +func (c *CollectionCommands) UpdateCollection(ctx context.Context, input UpdateCollectionInput) (*domain.Collection, error) { + collection, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + collection.Name = input.Name + collection.Description = input.Description + collection.IsPublic = input.IsPublic + collection.CoverImageURL = input.CoverImageURL + err = c.repo.Update(ctx, collection) + if err != nil { + return nil, err + } + return collection, nil +} + +// DeleteCollection deletes a collection by ID. +func (c *CollectionCommands) DeleteCollection(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} + +// AddWorkToCollectionInput represents the input for adding a work to a collection. +type AddWorkToCollectionInput struct { + CollectionID uint + WorkID uint +} + +// AddWorkToCollection adds a work to a collection. +func (c *CollectionCommands) AddWorkToCollection(ctx context.Context, input AddWorkToCollectionInput) error { + return c.repo.AddWorkToCollection(ctx, input.CollectionID, input.WorkID) +} + +// RemoveWorkFromCollectionInput represents the input for removing a work from a collection. +type RemoveWorkFromCollectionInput struct { + CollectionID uint + WorkID uint +} + +// RemoveWorkFromCollection removes a work from a collection. +func (c *CollectionCommands) RemoveWorkFromCollection(ctx context.Context, input RemoveWorkFromCollectionInput) error { + return c.repo.RemoveWorkFromCollection(ctx, input.CollectionID, input.WorkID) +} diff --git a/internal/app/collection/queries.go b/internal/app/collection/queries.go new file mode 100644 index 0000000..abfa7cd --- /dev/null +++ b/internal/app/collection/queries.go @@ -0,0 +1,41 @@ +package collection + +import ( + "context" + "tercul/internal/domain" +) + +// CollectionQueries contains the query handlers for the collection aggregate. +type CollectionQueries struct { + repo domain.CollectionRepository +} + +// NewCollectionQueries creates a new CollectionQueries handler. +func NewCollectionQueries(repo domain.CollectionRepository) *CollectionQueries { + return &CollectionQueries{repo: repo} +} + +// Collection returns a collection by ID. +func (q *CollectionQueries) Collection(ctx context.Context, id uint) (*domain.Collection, error) { + return q.repo.GetByID(ctx, id) +} + +// CollectionsByUserID returns all collections for a user. +func (q *CollectionQueries) CollectionsByUserID(ctx context.Context, userID uint) ([]domain.Collection, error) { + return q.repo.ListByUserID(ctx, userID) +} + +// PublicCollections returns all public collections. +func (q *CollectionQueries) PublicCollections(ctx context.Context) ([]domain.Collection, error) { + return q.repo.ListPublic(ctx) +} + +// CollectionsByWorkID returns all collections for a work. +func (q *CollectionQueries) CollectionsByWorkID(ctx context.Context, workID uint) ([]domain.Collection, error) { + return q.repo.ListByWorkID(ctx, workID) +} + +// Collections returns all collections. +func (q *CollectionQueries) Collections(ctx context.Context) ([]domain.Collection, error) { + return q.repo.ListAll(ctx) +} diff --git a/internal/app/collection/service.go b/internal/app/collection/service.go new file mode 100644 index 0000000..6229587 --- /dev/null +++ b/internal/app/collection/service.go @@ -0,0 +1,17 @@ +package collection + +import "tercul/internal/domain" + +// Service is the application service for the collection aggregate. +type Service struct { + Commands *CollectionCommands + Queries *CollectionQueries +} + +// NewService creates a new collection Service. +func NewService(repo domain.CollectionRepository) *Service { + return &Service{ + Commands: NewCollectionCommands(repo), + Queries: NewCollectionQueries(repo), + } +} diff --git a/internal/app/comment/commands.go b/internal/app/comment/commands.go new file mode 100644 index 0000000..82e13e0 --- /dev/null +++ b/internal/app/comment/commands.go @@ -0,0 +1,66 @@ +package comment + +import ( + "context" + "tercul/internal/domain" +) + +// CommentCommands contains the command handlers for the comment aggregate. +type CommentCommands struct { + repo domain.CommentRepository +} + +// NewCommentCommands creates a new CommentCommands handler. +func NewCommentCommands(repo domain.CommentRepository) *CommentCommands { + return &CommentCommands{repo: repo} +} + +// CreateCommentInput represents the input for creating a new comment. +type CreateCommentInput struct { + Text string + UserID uint + WorkID *uint + TranslationID *uint + ParentID *uint +} + +// CreateComment creates a new comment. +func (c *CommentCommands) CreateComment(ctx context.Context, input CreateCommentInput) (*domain.Comment, error) { + comment := &domain.Comment{ + Text: input.Text, + UserID: input.UserID, + WorkID: input.WorkID, + TranslationID: input.TranslationID, + ParentID: input.ParentID, + } + err := c.repo.Create(ctx, comment) + if err != nil { + return nil, err + } + return comment, nil +} + +// UpdateCommentInput represents the input for updating an existing comment. +type UpdateCommentInput struct { + ID uint + Text string +} + +// UpdateComment updates an existing comment. +func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) { + comment, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + comment.Text = input.Text + err = c.repo.Update(ctx, comment) + if err != nil { + return nil, err + } + return comment, nil +} + +// DeleteComment deletes a comment by ID. +func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/comment/queries.go b/internal/app/comment/queries.go new file mode 100644 index 0000000..7d7991d --- /dev/null +++ b/internal/app/comment/queries.go @@ -0,0 +1,46 @@ +package comment + +import ( + "context" + "tercul/internal/domain" +) + +// CommentQueries contains the query handlers for the comment aggregate. +type CommentQueries struct { + repo domain.CommentRepository +} + +// NewCommentQueries creates a new CommentQueries handler. +func NewCommentQueries(repo domain.CommentRepository) *CommentQueries { + return &CommentQueries{repo: repo} +} + +// Comment returns a comment by ID. +func (q *CommentQueries) Comment(ctx context.Context, id uint) (*domain.Comment, error) { + return q.repo.GetByID(ctx, id) +} + +// CommentsByUserID returns all comments for a user. +func (q *CommentQueries) CommentsByUserID(ctx context.Context, userID uint) ([]domain.Comment, error) { + return q.repo.ListByUserID(ctx, userID) +} + +// CommentsByWorkID returns all comments for a work. +func (q *CommentQueries) CommentsByWorkID(ctx context.Context, workID uint) ([]domain.Comment, error) { + return q.repo.ListByWorkID(ctx, workID) +} + +// CommentsByTranslationID returns all comments for a translation. +func (q *CommentQueries) CommentsByTranslationID(ctx context.Context, translationID uint) ([]domain.Comment, error) { + return q.repo.ListByTranslationID(ctx, translationID) +} + +// CommentsByParentID returns all comments for a parent. +func (q *CommentQueries) CommentsByParentID(ctx context.Context, parentID uint) ([]domain.Comment, error) { + return q.repo.ListByParentID(ctx, parentID) +} + +// Comments returns all comments. +func (q *CommentQueries) Comments(ctx context.Context) ([]domain.Comment, error) { + return q.repo.ListAll(ctx) +} diff --git a/internal/app/comment/service.go b/internal/app/comment/service.go new file mode 100644 index 0000000..23c449f --- /dev/null +++ b/internal/app/comment/service.go @@ -0,0 +1,17 @@ +package comment + +import "tercul/internal/domain" + +// Service is the application service for the comment aggregate. +type Service struct { + Commands *CommentCommands + Queries *CommentQueries +} + +// NewService creates a new comment Service. +func NewService(repo domain.CommentRepository) *Service { + return &Service{ + Commands: NewCommentCommands(repo), + Queries: NewCommentQueries(repo), + } +} diff --git a/internal/app/copyright/commands.go b/internal/app/copyright/commands.go index 261a282..64c39dd 100644 --- a/internal/app/copyright/commands.go +++ b/internal/app/copyright/commands.go @@ -4,6 +4,7 @@ import ( "context" "errors" "tercul/internal/domain" + "tercul/internal/platform/log" ) // CopyrightCommands contains the command handlers for copyright. @@ -27,6 +28,7 @@ func (c *CopyrightCommands) CreateCopyright(ctx context.Context, copyright *doma if copyright.Identificator == "" { return errors.New("copyright identificator cannot be empty") } + log.LogDebug("Creating copyright", log.F("name", copyright.Name)) return c.repo.Create(ctx, copyright) } @@ -44,6 +46,7 @@ func (c *CopyrightCommands) UpdateCopyright(ctx context.Context, copyright *doma if copyright.Identificator == "" { return errors.New("copyright identificator cannot be empty") } + log.LogDebug("Updating copyright", log.F("id", copyright.ID)) return c.repo.Update(ctx, copyright) } @@ -52,29 +55,98 @@ func (c *CopyrightCommands) DeleteCopyright(ctx context.Context, id uint) error if id == 0 { return errors.New("invalid copyright ID") } + log.LogDebug("Deleting copyright", log.F("id", id)) return c.repo.Delete(ctx, id) } -// AttachCopyrightToEntity attaches a copyright to any entity type. -func (c *CopyrightCommands) AttachCopyrightToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error { - if copyrightID == 0 || entityID == 0 { - return errors.New("invalid copyright ID or entity ID") +// AddCopyrightToWork adds a copyright to a work. +func (c *CopyrightCommands) AddCopyrightToWork(ctx context.Context, workID uint, copyrightID uint) error { + if workID == 0 || copyrightID == 0 { + return errors.New("invalid work ID or copyright ID") } - if entityType == "" { - return errors.New("entity type cannot be empty") - } - return c.repo.AttachToEntity(ctx, copyrightID, entityID, entityType) + log.LogDebug("Adding copyright to work", log.F("work_id", workID), log.F("copyright_id", copyrightID)) + return c.repo.AddCopyrightToWork(ctx, workID, copyrightID) } -// DetachCopyrightFromEntity removes a copyright from an entity. -func (c *CopyrightCommands) DetachCopyrightFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error { - if copyrightID == 0 || entityID == 0 { - return errors.New("invalid copyright ID or entity ID") +// RemoveCopyrightFromWork removes a copyright from a work. +func (c *CopyrightCommands) RemoveCopyrightFromWork(ctx context.Context, workID uint, copyrightID uint) error { + if workID == 0 || copyrightID == 0 { + return errors.New("invalid work ID or copyright ID") } - if entityType == "" { - return errors.New("entity type cannot be empty") + log.LogDebug("Removing copyright from work", log.F("work_id", workID), log.F("copyright_id", copyrightID)) + return c.repo.RemoveCopyrightFromWork(ctx, workID, copyrightID) +} + +// AddCopyrightToAuthor adds a copyright to an author. +func (c *CopyrightCommands) AddCopyrightToAuthor(ctx context.Context, authorID uint, copyrightID uint) error { + if authorID == 0 || copyrightID == 0 { + return errors.New("invalid author ID or copyright ID") } - return c.repo.DetachFromEntity(ctx, copyrightID, entityID, entityType) + log.LogDebug("Adding copyright to author", log.F("author_id", authorID), log.F("copyright_id", copyrightID)) + return c.repo.AddCopyrightToAuthor(ctx, authorID, copyrightID) +} + +// RemoveCopyrightFromAuthor removes a copyright from an author. +func (c *CopyrightCommands) RemoveCopyrightFromAuthor(ctx context.Context, authorID uint, copyrightID uint) error { + if authorID == 0 || copyrightID == 0 { + return errors.New("invalid author ID or copyright ID") + } + log.LogDebug("Removing copyright from author", log.F("author_id", authorID), log.F("copyright_id", copyrightID)) + return c.repo.RemoveCopyrightFromAuthor(ctx, authorID, copyrightID) +} + +// AddCopyrightToBook adds a copyright to a book. +func (c *CopyrightCommands) AddCopyrightToBook(ctx context.Context, bookID uint, copyrightID uint) error { + if bookID == 0 || copyrightID == 0 { + return errors.New("invalid book ID or copyright ID") + } + log.LogDebug("Adding copyright to book", log.F("book_id", bookID), log.F("copyright_id", copyrightID)) + return c.repo.AddCopyrightToBook(ctx, bookID, copyrightID) +} + +// RemoveCopyrightFromBook removes a copyright from a book. +func (c *CopyrightCommands) RemoveCopyrightFromBook(ctx context.Context, bookID uint, copyrightID uint) error { + if bookID == 0 || copyrightID == 0 { + return errors.New("invalid book ID or copyright ID") + } + log.LogDebug("Removing copyright from book", log.F("book_id", bookID), log.F("copyright_id", copyrightID)) + return c.repo.RemoveCopyrightFromBook(ctx, bookID, copyrightID) +} + +// AddCopyrightToPublisher adds a copyright to a publisher. +func (c *CopyrightCommands) AddCopyrightToPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { + if publisherID == 0 || copyrightID == 0 { + return errors.New("invalid publisher ID or copyright ID") + } + log.LogDebug("Adding copyright to publisher", log.F("publisher_id", publisherID), log.F("copyright_id", copyrightID)) + return c.repo.AddCopyrightToPublisher(ctx, publisherID, copyrightID) +} + +// RemoveCopyrightFromPublisher removes a copyright from a publisher. +func (c *CopyrightCommands) RemoveCopyrightFromPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { + if publisherID == 0 || copyrightID == 0 { + return errors.New("invalid publisher ID or copyright ID") + } + log.LogDebug("Removing copyright from publisher", log.F("publisher_id", publisherID), log.F("copyright_id", copyrightID)) + return c.repo.RemoveCopyrightFromPublisher(ctx, publisherID, copyrightID) +} + +// AddCopyrightToSource adds a copyright to a source. +func (c *CopyrightCommands) AddCopyrightToSource(ctx context.Context, sourceID uint, copyrightID uint) error { + if sourceID == 0 || copyrightID == 0 { + return errors.New("invalid source ID or copyright ID") + } + log.LogDebug("Adding copyright to source", log.F("source_id", sourceID), log.F("copyright_id", copyrightID)) + return c.repo.AddCopyrightToSource(ctx, sourceID, copyrightID) +} + +// RemoveCopyrightFromSource removes a copyright from a source. +func (c *CopyrightCommands) RemoveCopyrightFromSource(ctx context.Context, sourceID uint, copyrightID uint) error { + if sourceID == 0 || copyrightID == 0 { + return errors.New("invalid source ID or copyright ID") + } + log.LogDebug("Removing copyright from source", log.F("source_id", sourceID), log.F("copyright_id", copyrightID)) + return c.repo.RemoveCopyrightFromSource(ctx, sourceID, copyrightID) } // AddTranslation adds a translation to a copyright. @@ -91,5 +163,6 @@ func (c *CopyrightCommands) AddTranslation(ctx context.Context, translation *dom if translation.Message == "" { return errors.New("translation message cannot be empty") } + log.LogDebug("Adding translation to copyright", log.F("copyright_id", translation.CopyrightID), log.F("language", translation.LanguageCode)) return c.repo.AddTranslation(ctx, translation) } diff --git a/internal/app/copyright/commands_integration_test.go b/internal/app/copyright/commands_integration_test.go new file mode 100644 index 0000000..1c098a7 --- /dev/null +++ b/internal/app/copyright/commands_integration_test.go @@ -0,0 +1,239 @@ +//go:build integration + +package copyright_test + +import ( + "context" + "testing" + "tercul/internal/app/copyright" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type CopyrightCommandsTestSuite struct { + testutil.IntegrationTestSuite + commands *copyright.CopyrightCommands +} + +func (s *CopyrightCommandsTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.commands = copyright.NewCopyrightCommands(s.CopyrightRepo) +} + +func (s *CopyrightCommandsTestSuite) TestAddCopyrightToWork() { + s.Run("should add a copyright to a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + + // Act + err := s.commands.AddCopyrightToWork(context.Background(), work.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + + // Verify that the association was created in the database + var foundWork domain.Work + err = s.DB.Preload("Copyrights").First(&foundWork, work.ID).Error + s.Require().NoError(err) + s.Require().Len(foundWork.Copyrights, 1) + s.Equal(copyright.ID, foundWork.Copyrights[0].ID) + }) +} + +func (s *CopyrightCommandsTestSuite) TestRemoveCopyrightFromWork() { + s.Run("should remove a copyright from a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + s.Require().NoError(s.commands.AddCopyrightToWork(context.Background(), work.ID, copyright.ID)) + + // Act + err := s.commands.RemoveCopyrightFromWork(context.Background(), work.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + + // Verify that the association was removed from the database + var foundWork domain.Work + err = s.DB.Preload("Copyrights").First(&foundWork, work.ID).Error + s.Require().NoError(err) + s.Require().Len(foundWork.Copyrights, 0) + }) +} + +func (s *CopyrightCommandsTestSuite) TestAddCopyrightToAuthor() { + s.Run("should add a copyright to an author", func() { + // Arrange + author := &domain.Author{Name: "Test Author"} + s.Require().NoError(s.AuthorRepo.Create(context.Background(), author)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + + // Act + err := s.commands.AddCopyrightToAuthor(context.Background(), author.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundAuthor domain.Author + err = s.DB.Preload("Copyrights").First(&foundAuthor, author.ID).Error + s.Require().NoError(err) + s.Require().Len(foundAuthor.Copyrights, 1) + s.Equal(copyright.ID, foundAuthor.Copyrights[0].ID) + }) +} + +func (s *CopyrightCommandsTestSuite) TestRemoveCopyrightFromAuthor() { + s.Run("should remove a copyright from an author", func() { + // Arrange + author := &domain.Author{Name: "Test Author"} + s.Require().NoError(s.AuthorRepo.Create(context.Background(), author)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + s.Require().NoError(s.commands.AddCopyrightToAuthor(context.Background(), author.ID, copyright.ID)) + + // Act + err := s.commands.RemoveCopyrightFromAuthor(context.Background(), author.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundAuthor domain.Author + err = s.DB.Preload("Copyrights").First(&foundAuthor, author.ID).Error + s.Require().NoError(err) + s.Require().Len(foundAuthor.Copyrights, 0) + }) +} + +func (s *CopyrightCommandsTestSuite) TestAddCopyrightToBook() { + s.Run("should add a copyright to a book", func() { + // Arrange + book := &domain.Book{Title: "Test Book"} + s.Require().NoError(s.BookRepo.Create(context.Background(), book)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + + // Act + err := s.commands.AddCopyrightToBook(context.Background(), book.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundBook domain.Book + err = s.DB.Preload("Copyrights").First(&foundBook, book.ID).Error + s.Require().NoError(err) + s.Require().Len(foundBook.Copyrights, 1) + s.Equal(copyright.ID, foundBook.Copyrights[0].ID) + }) +} + +func (s *CopyrightCommandsTestSuite) TestRemoveCopyrightFromBook() { + s.Run("should remove a copyright from a book", func() { + // Arrange + book := &domain.Book{Title: "Test Book"} + s.Require().NoError(s.BookRepo.Create(context.Background(), book)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + s.Require().NoError(s.commands.AddCopyrightToBook(context.Background(), book.ID, copyright.ID)) + + // Act + err := s.commands.RemoveCopyrightFromBook(context.Background(), book.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundBook domain.Book + err = s.DB.Preload("Copyrights").First(&foundBook, book.ID).Error + s.Require().NoError(err) + s.Require().Len(foundBook.Copyrights, 0) + }) +} + +func (s *CopyrightCommandsTestSuite) TestAddCopyrightToPublisher() { + s.Run("should add a copyright to a publisher", func() { + // Arrange + publisher := &domain.Publisher{Name: "Test Publisher"} + s.Require().NoError(s.PublisherRepo.Create(context.Background(), publisher)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + + // Act + err := s.commands.AddCopyrightToPublisher(context.Background(), publisher.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundPublisher domain.Publisher + err = s.DB.Preload("Copyrights").First(&foundPublisher, publisher.ID).Error + s.Require().NoError(err) + s.Require().Len(foundPublisher.Copyrights, 1) + s.Equal(copyright.ID, foundPublisher.Copyrights[0].ID) + }) +} + +func (s *CopyrightCommandsTestSuite) TestRemoveCopyrightFromPublisher() { + s.Run("should remove a copyright from a publisher", func() { + // Arrange + publisher := &domain.Publisher{Name: "Test Publisher"} + s.Require().NoError(s.PublisherRepo.Create(context.Background(), publisher)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + s.Require().NoError(s.commands.AddCopyrightToPublisher(context.Background(), publisher.ID, copyright.ID)) + + // Act + err := s.commands.RemoveCopyrightFromPublisher(context.Background(), publisher.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundPublisher domain.Publisher + err = s.DB.Preload("Copyrights").First(&foundPublisher, publisher.ID).Error + s.Require().NoError(err) + s.Require().Len(foundPublisher.Copyrights, 0) + }) +} + +func (s *CopyrightCommandsTestSuite) TestAddCopyrightToSource() { + s.Run("should add a copyright to a source", func() { + // Arrange + source := &domain.Source{Name: "Test Source"} + s.Require().NoError(s.SourceRepo.Create(context.Background(), source)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + + // Act + err := s.commands.AddCopyrightToSource(context.Background(), source.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundSource domain.Source + err = s.DB.Preload("Copyrights").First(&foundSource, source.ID).Error + s.Require().NoError(err) + s.Require().Len(foundSource.Copyrights, 1) + s.Equal(copyright.ID, foundSource.Copyrights[0].ID) + }) +} + +func (s *CopyrightCommandsTestSuite) TestRemoveCopyrightFromSource() { + s.Run("should remove a copyright from a source", func() { + // Arrange + source := &domain.Source{Name: "Test Source"} + s.Require().NoError(s.SourceRepo.Create(context.Background(), source)) + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.Require().NoError(s.CopyrightRepo.Create(context.Background(), copyright)) + s.Require().NoError(s.commands.AddCopyrightToSource(context.Background(), source.ID, copyright.ID)) + + // Act + err := s.commands.RemoveCopyrightFromSource(context.Background(), source.ID, copyright.ID) + + // Assert + s.Require().NoError(err) + var foundSource domain.Source + err = s.DB.Preload("Copyrights").First(&foundSource, source.ID).Error + s.Require().NoError(err) + s.Require().Len(foundSource.Copyrights, 0) + }) +} + +func TestCopyrightCommands(t *testing.T) { + suite.Run(t, new(CopyrightCommandsTestSuite)) +} diff --git a/internal/app/copyright/commands_test.go b/internal/app/copyright/commands_test.go new file mode 100644 index 0000000..1d48456 --- /dev/null +++ b/internal/app/copyright/commands_test.go @@ -0,0 +1,349 @@ +package copyright + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "testing" +) + +type CopyrightCommandsSuite struct { + suite.Suite + repo *mockCopyrightRepository + commands *CopyrightCommands +} + +func (s *CopyrightCommandsSuite) SetupTest() { + s.repo = &mockCopyrightRepository{} + s.commands = NewCopyrightCommands(s.repo) +} + +func TestCopyrightCommandsSuite(t *testing.T) { + suite.Run(t, new(CopyrightCommandsSuite)) +} + +func (s *CopyrightCommandsSuite) TestCreateCopyright_Success() { + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.repo.createFunc = func(ctx context.Context, c *domain.Copyright) error { + assert.Equal(s.T(), copyright.Name, c.Name) + return nil + } + err := s.commands.CreateCopyright(context.Background(), copyright) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestCreateCopyright_Nil() { + err := s.commands.CreateCopyright(context.Background(), nil) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestCreateCopyright_EmptyName() { + copyright := &domain.Copyright{Identificator: "TC-123"} + err := s.commands.CreateCopyright(context.Background(), copyright) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestCreateCopyright_EmptyIdentificator() { + copyright := &domain.Copyright{Name: "Test Copyright"} + err := s.commands.CreateCopyright(context.Background(), copyright) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestCreateCopyright_RepoError() { + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + s.repo.createFunc = func(ctx context.Context, c *domain.Copyright) error { + return errors.New("db error") + } + err := s.commands.CreateCopyright(context.Background(), copyright) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestUpdateCopyright_Success() { + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + copyright.ID = 1 + s.repo.updateFunc = func(ctx context.Context, c *domain.Copyright) error { + assert.Equal(s.T(), copyright.Name, c.Name) + return nil + } + err := s.commands.UpdateCopyright(context.Background(), copyright) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestUpdateCopyright_Nil() { + err := s.commands.UpdateCopyright(context.Background(), nil) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestUpdateCopyright_ZeroID() { + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + err := s.commands.UpdateCopyright(context.Background(), copyright) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestUpdateCopyright_EmptyName() { + copyright := &domain.Copyright{Identificator: "TC-123"} + copyright.ID = 1 + err := s.commands.UpdateCopyright(context.Background(), copyright) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestUpdateCopyright_EmptyIdentificator() { + copyright := &domain.Copyright{Name: "Test Copyright"} + copyright.ID = 1 + err := s.commands.UpdateCopyright(context.Background(), copyright) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestUpdateCopyright_RepoError() { + copyright := &domain.Copyright{Name: "Test Copyright", Identificator: "TC-123"} + copyright.ID = 1 + s.repo.updateFunc = func(ctx context.Context, c *domain.Copyright) error { + return errors.New("db error") + } + err := s.commands.UpdateCopyright(context.Background(), copyright) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestDeleteCopyright_Success() { + s.repo.deleteFunc = func(ctx context.Context, id uint) error { + assert.Equal(s.T(), uint(1), id) + return nil + } + err := s.commands.DeleteCopyright(context.Background(), 1) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestDeleteCopyright_ZeroID() { + err := s.commands.DeleteCopyright(context.Background(), 0) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestDeleteCopyright_RepoError() { + s.repo.deleteFunc = func(ctx context.Context, id uint) error { + return errors.New("db error") + } + err := s.commands.DeleteCopyright(context.Background(), 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToWork_Success() { + err := s.commands.AddCopyrightToWork(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToWork_ZeroID() { + err := s.commands.AddCopyrightToWork(context.Background(), 0, 2) + assert.Error(s.T(), err) + err = s.commands.AddCopyrightToWork(context.Background(), 1, 0) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromWork_Success() { + err := s.commands.RemoveCopyrightFromWork(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToWork_RepoError() { + s.repo.addCopyrightToWorkFunc = func(ctx context.Context, workID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.AddCopyrightToWork(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromWork_RepoError() { + s.repo.removeCopyrightFromWorkFunc = func(ctx context.Context, workID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveCopyrightFromWork(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromWork_ZeroID() { + err := s.commands.RemoveCopyrightFromWork(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToAuthor_ZeroID() { + err := s.commands.AddCopyrightToAuthor(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromAuthor_ZeroID() { + err := s.commands.RemoveCopyrightFromAuthor(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToBook_ZeroID() { + err := s.commands.AddCopyrightToBook(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromBook_ZeroID() { + err := s.commands.RemoveCopyrightFromBook(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToPublisher_ZeroID() { + err := s.commands.AddCopyrightToPublisher(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromPublisher_ZeroID() { + err := s.commands.RemoveCopyrightFromPublisher(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToSource_ZeroID() { + err := s.commands.AddCopyrightToSource(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromSource_ZeroID() { + err := s.commands.RemoveCopyrightFromSource(context.Background(), 0, 1) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToAuthor_RepoError() { + s.repo.addCopyrightToAuthorFunc = func(ctx context.Context, authorID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.AddCopyrightToAuthor(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromAuthor_RepoError() { + s.repo.removeCopyrightFromAuthorFunc = func(ctx context.Context, authorID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveCopyrightFromAuthor(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToBook_RepoError() { + s.repo.addCopyrightToBookFunc = func(ctx context.Context, bookID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.AddCopyrightToBook(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromBook_RepoError() { + s.repo.removeCopyrightFromBookFunc = func(ctx context.Context, bookID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveCopyrightFromBook(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToPublisher_RepoError() { + s.repo.addCopyrightToPublisherFunc = func(ctx context.Context, publisherID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.AddCopyrightToPublisher(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromPublisher_RepoError() { + s.repo.removeCopyrightFromPublisherFunc = func(ctx context.Context, publisherID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveCopyrightFromPublisher(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToSource_RepoError() { + s.repo.addCopyrightToSourceFunc = func(ctx context.Context, sourceID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.AddCopyrightToSource(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromSource_RepoError() { + s.repo.removeCopyrightFromSourceFunc = func(ctx context.Context, sourceID uint, copyrightID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveCopyrightFromSource(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToAuthor_Success() { + err := s.commands.AddCopyrightToAuthor(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromAuthor_Success() { + err := s.commands.RemoveCopyrightFromAuthor(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToBook_Success() { + err := s.commands.AddCopyrightToBook(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromBook_Success() { + err := s.commands.RemoveCopyrightFromBook(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToPublisher_Success() { + err := s.commands.AddCopyrightToPublisher(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromPublisher_Success() { + err := s.commands.RemoveCopyrightFromPublisher(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddCopyrightToSource_Success() { + err := s.commands.AddCopyrightToSource(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestRemoveCopyrightFromSource_Success() { + err := s.commands.RemoveCopyrightFromSource(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddTranslation_Success() { + translation := &domain.CopyrightTranslation{CopyrightID: 1, LanguageCode: "en", Message: "Test"} + err := s.commands.AddTranslation(context.Background(), translation) + assert.NoError(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddTranslation_Nil() { + err := s.commands.AddTranslation(context.Background(), nil) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddTranslation_ZeroCopyrightID() { + translation := &domain.CopyrightTranslation{LanguageCode: "en", Message: "Test"} + err := s.commands.AddTranslation(context.Background(), translation) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddTranslation_RepoError() { + translation := &domain.CopyrightTranslation{CopyrightID: 1, LanguageCode: "en", Message: "Test"} + s.repo.addTranslationFunc = func(ctx context.Context, t *domain.CopyrightTranslation) error { + return errors.New("db error") + } + err := s.commands.AddTranslation(context.Background(), translation) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddTranslation_EmptyLanguageCode() { + translation := &domain.CopyrightTranslation{CopyrightID: 1, Message: "Test"} + err := s.commands.AddTranslation(context.Background(), translation) + assert.Error(s.T(), err) +} + +func (s *CopyrightCommandsSuite) TestAddTranslation_EmptyMessage() { + translation := &domain.CopyrightTranslation{CopyrightID: 1, LanguageCode: "en"} + err := s.commands.AddTranslation(context.Background(), translation) + assert.Error(s.T(), err) +} diff --git a/internal/app/copyright/main_test.go b/internal/app/copyright/main_test.go new file mode 100644 index 0000000..79cef2a --- /dev/null +++ b/internal/app/copyright/main_test.go @@ -0,0 +1,223 @@ +package copyright + +import ( + "context" + "gorm.io/gorm" + "tercul/internal/domain" +) + +type mockCopyrightRepository struct { + createFunc func(ctx context.Context, copyright *domain.Copyright) error + updateFunc func(ctx context.Context, copyright *domain.Copyright) error + deleteFunc func(ctx context.Context, id uint) error + addCopyrightToWorkFunc func(ctx context.Context, workID uint, copyrightID uint) error + removeCopyrightFromWorkFunc func(ctx context.Context, workID uint, copyrightID uint) error + addCopyrightToAuthorFunc func(ctx context.Context, authorID uint, copyrightID uint) error + removeCopyrightFromAuthorFunc func(ctx context.Context, authorID uint, copyrightID uint) error + addCopyrightToBookFunc func(ctx context.Context, bookID uint, copyrightID uint) error + removeCopyrightFromBookFunc func(ctx context.Context, bookID uint, copyrightID uint) error + addCopyrightToPublisherFunc func(ctx context.Context, publisherID uint, copyrightID uint) error + removeCopyrightFromPublisherFunc func(ctx context.Context, publisherID uint, copyrightID uint) error + addCopyrightToSourceFunc func(ctx context.Context, sourceID uint, copyrightID uint) error + removeCopyrightFromSourceFunc func(ctx context.Context, sourceID uint, copyrightID uint) error + addTranslationFunc func(ctx context.Context, translation *domain.CopyrightTranslation) error + getByIDFunc func(ctx context.Context, id uint) (*domain.Copyright, error) + listAllFunc func(ctx context.Context) ([]domain.Copyright, error) + getTranslationsFunc func(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) + getTranslationByLanguageFunc func(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) +} + +func (m *mockCopyrightRepository) Create(ctx context.Context, copyright *domain.Copyright) error { + if m.createFunc != nil { + return m.createFunc(ctx, copyright) + } + return nil +} +func (m *mockCopyrightRepository) Update(ctx context.Context, copyright *domain.Copyright) error { + if m.updateFunc != nil { + return m.updateFunc(ctx, copyright) + } + return nil +} +func (m *mockCopyrightRepository) Delete(ctx context.Context, id uint) error { + if m.deleteFunc != nil { + return m.deleteFunc(ctx, id) + } + return nil +} +func (m *mockCopyrightRepository) AddCopyrightToWork(ctx context.Context, workID uint, copyrightID uint) error { + if m.addCopyrightToWorkFunc != nil { + return m.addCopyrightToWorkFunc(ctx, workID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) RemoveCopyrightFromWork(ctx context.Context, workID uint, copyrightID uint) error { + if m.removeCopyrightFromWorkFunc != nil { + return m.removeCopyrightFromWorkFunc(ctx, workID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) AddCopyrightToAuthor(ctx context.Context, authorID uint, copyrightID uint) error { + if m.addCopyrightToAuthorFunc != nil { + return m.addCopyrightToAuthorFunc(ctx, authorID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) RemoveCopyrightFromAuthor(ctx context.Context, authorID uint, copyrightID uint) error { + if m.removeCopyrightFromAuthorFunc != nil { + return m.removeCopyrightFromAuthorFunc(ctx, authorID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) AddCopyrightToBook(ctx context.Context, bookID uint, copyrightID uint) error { + if m.addCopyrightToBookFunc != nil { + return m.addCopyrightToBookFunc(ctx, bookID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) RemoveCopyrightFromBook(ctx context.Context, bookID uint, copyrightID uint) error { + if m.removeCopyrightFromBookFunc != nil { + return m.removeCopyrightFromBookFunc(ctx, bookID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) AddCopyrightToPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { + if m.addCopyrightToPublisherFunc != nil { + return m.addCopyrightToPublisherFunc(ctx, publisherID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) RemoveCopyrightFromPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { + if m.removeCopyrightFromPublisherFunc != nil { + return m.removeCopyrightFromPublisherFunc(ctx, publisherID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) AddCopyrightToSource(ctx context.Context, sourceID uint, copyrightID uint) error { + if m.addCopyrightToSourceFunc != nil { + return m.addCopyrightToSourceFunc(ctx, sourceID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) RemoveCopyrightFromSource(ctx context.Context, sourceID uint, copyrightID uint) error { + if m.removeCopyrightFromSourceFunc != nil { + return m.removeCopyrightFromSourceFunc(ctx, sourceID, copyrightID) + } + return nil +} +func (m *mockCopyrightRepository) AddTranslation(ctx context.Context, translation *domain.CopyrightTranslation) error { + if m.addTranslationFunc != nil { + return m.addTranslationFunc(ctx, translation) + } + return nil +} +func (m *mockCopyrightRepository) GetByID(ctx context.Context, id uint) (*domain.Copyright, error) { + if m.getByIDFunc != nil { + return m.getByIDFunc(ctx, id) + } + return nil, nil +} +func (m *mockCopyrightRepository) ListAll(ctx context.Context) ([]domain.Copyright, error) { + if m.listAllFunc != nil { + return m.listAllFunc(ctx) + } + return nil, nil +} +func (m *mockCopyrightRepository) GetTranslations(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) { + if m.getTranslationsFunc != nil { + return m.getTranslationsFunc(ctx, copyrightID) + } + return nil, nil +} +func (m *mockCopyrightRepository) GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) { + if m.getTranslationByLanguageFunc != nil { + return m.getTranslationByLanguageFunc(ctx, copyrightID, languageCode) + } + return nil, nil +} + +// Implement the rest of the CopyrightRepository interface with empty methods. +func (m *mockCopyrightRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Copyright], error) { + return nil, nil +} +func (m *mockCopyrightRepository) Count(ctx context.Context) (int64, error) { return 0, nil } +func (m *mockCopyrightRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Copyright) error { + return nil +} +func (m *mockCopyrightRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Copyright, error) { + return nil, nil +} +func (m *mockCopyrightRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Copyright) error { + return nil +} +func (m *mockCopyrightRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + return nil +} +func (m *mockCopyrightRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Copyright, error) { + return nil, nil +} +func (m *mockCopyrightRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + return 0, nil +} +func (m *mockCopyrightRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Copyright, error) { + return nil, nil +} +func (m *mockCopyrightRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Copyright, error) { + return nil, nil +} +func (m *mockCopyrightRepository) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } +func (m *mockCopyrightRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } +func (m *mockCopyrightRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return nil +} + +type mockWorkRepository struct { + domain.WorkRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) +} +func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockAuthorRepository struct { + domain.AuthorRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) +} +func (m *mockAuthorRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockBookRepository struct { + domain.BookRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) +} +func (m *mockBookRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockPublisherRepository struct { + domain.PublisherRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) +} +func (m *mockPublisherRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockSourceRepository struct { + domain.SourceRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) +} +func (m *mockSourceRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} diff --git a/internal/app/copyright/queries.go b/internal/app/copyright/queries.go index a91c797..ac29cdb 100644 --- a/internal/app/copyright/queries.go +++ b/internal/app/copyright/queries.go @@ -4,16 +4,22 @@ import ( "context" "errors" "tercul/internal/domain" + "tercul/internal/platform/log" ) // CopyrightQueries contains the query handlers for copyright. type CopyrightQueries struct { - repo domain.CopyrightRepository + repo domain.CopyrightRepository + workRepo domain.WorkRepository + authorRepo domain.AuthorRepository + bookRepo domain.BookRepository + publisherRepo domain.PublisherRepository + sourceRepo domain.SourceRepository } // NewCopyrightQueries creates a new CopyrightQueries handler. -func NewCopyrightQueries(repo domain.CopyrightRepository) *CopyrightQueries { - return &CopyrightQueries{repo: repo} +func NewCopyrightQueries(repo domain.CopyrightRepository, workRepo domain.WorkRepository, authorRepo domain.AuthorRepository, bookRepo domain.BookRepository, publisherRepo domain.PublisherRepository, sourceRepo domain.SourceRepository) *CopyrightQueries { + return &CopyrightQueries{repo: repo, workRepo: workRepo, authorRepo: authorRepo, bookRepo: bookRepo, publisherRepo: publisherRepo, sourceRepo: sourceRepo} } // GetCopyrightByID retrieves a copyright by ID. @@ -21,33 +27,66 @@ func (q *CopyrightQueries) GetCopyrightByID(ctx context.Context, id uint) (*doma if id == 0 { return nil, errors.New("invalid copyright ID") } + log.LogDebug("Getting copyright by ID", log.F("id", id)) return q.repo.GetByID(ctx, id) } // ListCopyrights retrieves all copyrights. func (q *CopyrightQueries) ListCopyrights(ctx context.Context) ([]domain.Copyright, error) { + log.LogDebug("Listing all copyrights") // Note: This might need pagination in the future. // For now, it mirrors the old service's behavior. return q.repo.ListAll(ctx) } -// GetCopyrightsForEntity gets all copyrights for a specific entity. -func (q *CopyrightQueries) GetCopyrightsForEntity(ctx context.Context, entityID uint, entityType string) ([]domain.Copyright, error) { - if entityID == 0 { - return nil, errors.New("invalid entity ID") +// GetCopyrightsForWork gets all copyrights for a specific work. +func (q *CopyrightQueries) GetCopyrightsForWork(ctx context.Context, workID uint) ([]*domain.Copyright, error) { + log.LogDebug("Getting copyrights for work", log.F("work_id", workID)) + work, err := q.workRepo.GetByIDWithOptions(ctx, workID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) + if err != nil { + return nil, err } - if entityType == "" { - return nil, errors.New("entity type cannot be empty") - } - return q.repo.GetByEntity(ctx, entityID, entityType) + return work.Copyrights, nil } -// GetEntitiesByCopyright gets all entities that have a specific copyright. -func (q *CopyrightQueries) GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]domain.Copyrightable, error) { - if copyrightID == 0 { - return nil, errors.New("invalid copyright ID") +// GetCopyrightsForAuthor gets all copyrights for a specific author. +func (q *CopyrightQueries) GetCopyrightsForAuthor(ctx context.Context, authorID uint) ([]*domain.Copyright, error) { + log.LogDebug("Getting copyrights for author", log.F("author_id", authorID)) + author, err := q.authorRepo.GetByIDWithOptions(ctx, authorID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) + if err != nil { + return nil, err } - return q.repo.GetEntitiesByCopyright(ctx, copyrightID) + return author.Copyrights, nil +} + +// GetCopyrightsForBook gets all copyrights for a specific book. +func (q *CopyrightQueries) GetCopyrightsForBook(ctx context.Context, bookID uint) ([]*domain.Copyright, error) { + log.LogDebug("Getting copyrights for book", log.F("book_id", bookID)) + book, err := q.bookRepo.GetByIDWithOptions(ctx, bookID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) + if err != nil { + return nil, err + } + return book.Copyrights, nil +} + +// GetCopyrightsForPublisher gets all copyrights for a specific publisher. +func (q *CopyrightQueries) GetCopyrightsForPublisher(ctx context.Context, publisherID uint) ([]*domain.Copyright, error) { + log.LogDebug("Getting copyrights for publisher", log.F("publisher_id", publisherID)) + publisher, err := q.publisherRepo.GetByIDWithOptions(ctx, publisherID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) + if err != nil { + return nil, err + } + return publisher.Copyrights, nil +} + +// GetCopyrightsForSource gets all copyrights for a specific source. +func (q *CopyrightQueries) GetCopyrightsForSource(ctx context.Context, sourceID uint) ([]*domain.Copyright, error) { + log.LogDebug("Getting copyrights for source", log.F("source_id", sourceID)) + source, err := q.sourceRepo.GetByIDWithOptions(ctx, sourceID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) + if err != nil { + return nil, err + } + return source.Copyrights, nil } // GetTranslations gets all translations for a copyright. @@ -55,6 +94,7 @@ func (q *CopyrightQueries) GetTranslations(ctx context.Context, copyrightID uint if copyrightID == 0 { return nil, errors.New("invalid copyright ID") } + log.LogDebug("Getting translations for copyright", log.F("copyright_id", copyrightID)) return q.repo.GetTranslations(ctx, copyrightID) } @@ -66,5 +106,6 @@ func (q *CopyrightQueries) GetTranslationByLanguage(ctx context.Context, copyrig if languageCode == "" { return nil, errors.New("language code cannot be empty") } + log.LogDebug("Getting translation by language for copyright", log.F("copyright_id", copyrightID), log.F("language", languageCode)) return q.repo.GetTranslationByLanguage(ctx, copyrightID, languageCode) } diff --git a/internal/app/copyright/queries_test.go b/internal/app/copyright/queries_test.go new file mode 100644 index 0000000..bf52d31 --- /dev/null +++ b/internal/app/copyright/queries_test.go @@ -0,0 +1,231 @@ +package copyright + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "testing" +) + +type CopyrightQueriesSuite struct { + suite.Suite + repo *mockCopyrightRepository + workRepo *mockWorkRepository + authorRepo *mockAuthorRepository + bookRepo *mockBookRepository + publisherRepo *mockPublisherRepository + sourceRepo *mockSourceRepository + queries *CopyrightQueries +} + +func (s *CopyrightQueriesSuite) SetupTest() { + s.repo = &mockCopyrightRepository{} + s.workRepo = &mockWorkRepository{} + s.authorRepo = &mockAuthorRepository{} + s.bookRepo = &mockBookRepository{} + s.publisherRepo = &mockPublisherRepository{} + s.sourceRepo = &mockSourceRepository{} + s.queries = NewCopyrightQueries(s.repo, s.workRepo, s.authorRepo, s.bookRepo, s.publisherRepo, s.sourceRepo) +} + +func TestCopyrightQueriesSuite(t *testing.T) { + suite.Run(t, new(CopyrightQueriesSuite)) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightByID_Success() { + copyright := &domain.Copyright{Name: "Test Copyright"} + copyright.ID = 1 + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Copyright, error) { + return copyright, nil + } + c, err := s.queries.GetCopyrightByID(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), copyright, c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightByID_ZeroID() { + c, err := s.queries.GetCopyrightByID(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestListCopyrights_Success() { + copyrights := []domain.Copyright{{Name: "Test Copyright"}} + s.repo.listAllFunc = func(ctx context.Context) ([]domain.Copyright, error) { + return copyrights, nil + } + c, err := s.queries.ListCopyrights(context.Background()) + assert.NoError(s.T(), err) + assert.Equal(s.T(), copyrights, c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForAuthor_RepoError() { + s.authorRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) { + return nil, errors.New("db error") + } + c, err := s.queries.GetCopyrightsForAuthor(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForBook_RepoError() { + s.bookRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) { + return nil, errors.New("db error") + } + c, err := s.queries.GetCopyrightsForBook(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForPublisher_RepoError() { + s.publisherRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) { + return nil, errors.New("db error") + } + c, err := s.queries.GetCopyrightsForPublisher(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForSource_RepoError() { + s.sourceRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) { + return nil, errors.New("db error") + } + c, err := s.queries.GetCopyrightsForSource(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForWork_Success() { + copyrights := []*domain.Copyright{{Name: "Test Copyright"}} + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + return &domain.Work{Copyrights: copyrights}, nil + } + c, err := s.queries.GetCopyrightsForWork(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), copyrights, c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForWork_RepoError() { + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + return nil, errors.New("db error") + } + c, err := s.queries.GetCopyrightsForWork(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForAuthor_Success() { + copyrights := []*domain.Copyright{{Name: "Test Copyright"}} + s.authorRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) { + return &domain.Author{Copyrights: copyrights}, nil + } + c, err := s.queries.GetCopyrightsForAuthor(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), copyrights, c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForBook_Success() { + copyrights := []*domain.Copyright{{Name: "Test Copyright"}} + s.bookRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) { + return &domain.Book{Copyrights: copyrights}, nil + } + c, err := s.queries.GetCopyrightsForBook(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), copyrights, c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForPublisher_Success() { + copyrights := []*domain.Copyright{{Name: "Test Copyright"}} + s.publisherRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) { + return &domain.Publisher{Copyrights: copyrights}, nil + } + c, err := s.queries.GetCopyrightsForPublisher(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), copyrights, c) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightsForSource_Success() { + copyrights := []*domain.Copyright{{Name: "Test Copyright"}} + s.sourceRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) { + return &domain.Source{Copyrights: copyrights}, nil + } + c, err := s.queries.GetCopyrightsForSource(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), copyrights, c) +} + +func (s *CopyrightQueriesSuite) TestGetTranslations_Success() { + translations := []domain.CopyrightTranslation{{Message: "Test"}} + s.repo.getTranslationsFunc = func(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) { + return translations, nil + } + t, err := s.queries.GetTranslations(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), translations, t) +} + +func (s *CopyrightQueriesSuite) TestGetTranslations_ZeroID() { + t, err := s.queries.GetTranslations(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), t) +} + +func (s *CopyrightQueriesSuite) TestGetCopyrightByID_RepoError() { + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Copyright, error) { + return nil, errors.New("db error") + } + c, err := s.queries.GetCopyrightByID(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestListCopyrights_RepoError() { + s.repo.listAllFunc = func(ctx context.Context) ([]domain.Copyright, error) { + return nil, errors.New("db error") + } + c, err := s.queries.ListCopyrights(context.Background()) + assert.Error(s.T(), err) + assert.Nil(s.T(), c) +} + +func (s *CopyrightQueriesSuite) TestGetTranslations_RepoError() { + s.repo.getTranslationsFunc = func(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) { + return nil, errors.New("db error") + } + t, err := s.queries.GetTranslations(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), t) +} + +func (s *CopyrightQueriesSuite) TestGetTranslationByLanguage_RepoError() { + s.repo.getTranslationByLanguageFunc = func(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) { + return nil, errors.New("db error") + } + t, err := s.queries.GetTranslationByLanguage(context.Background(), 1, "en") + assert.Error(s.T(), err) + assert.Nil(s.T(), t) +} + +func (s *CopyrightQueriesSuite) TestGetTranslationByLanguage_Success() { + translation := &domain.CopyrightTranslation{Message: "Test"} + s.repo.getTranslationByLanguageFunc = func(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) { + return translation, nil + } + t, err := s.queries.GetTranslationByLanguage(context.Background(), 1, "en") + assert.NoError(s.T(), err) + assert.Equal(s.T(), translation, t) +} + +func (s *CopyrightQueriesSuite) TestGetTranslationByLanguage_ZeroID() { + t, err := s.queries.GetTranslationByLanguage(context.Background(), 0, "en") + assert.Error(s.T(), err) + assert.Nil(s.T(), t) +} + +func (s *CopyrightQueriesSuite) TestGetTranslationByLanguage_EmptyLang() { + t, err := s.queries.GetTranslationByLanguage(context.Background(), 1, "") + assert.Error(s.T(), err) + assert.Nil(s.T(), t) +} diff --git a/internal/app/like/commands.go b/internal/app/like/commands.go new file mode 100644 index 0000000..79d2097 --- /dev/null +++ b/internal/app/like/commands.go @@ -0,0 +1,44 @@ +package like + +import ( + "context" + "tercul/internal/domain" +) + +// LikeCommands contains the command handlers for the like aggregate. +type LikeCommands struct { + repo domain.LikeRepository +} + +// NewLikeCommands creates a new LikeCommands handler. +func NewLikeCommands(repo domain.LikeRepository) *LikeCommands { + return &LikeCommands{repo: repo} +} + +// CreateLikeInput represents the input for creating a new like. +type CreateLikeInput struct { + UserID uint + WorkID *uint + TranslationID *uint + CommentID *uint +} + +// CreateLike creates a new like. +func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (*domain.Like, error) { + like := &domain.Like{ + UserID: input.UserID, + WorkID: input.WorkID, + TranslationID: input.TranslationID, + CommentID: input.CommentID, + } + err := c.repo.Create(ctx, like) + if err != nil { + return nil, err + } + return like, nil +} + +// DeleteLike deletes a like by ID. +func (c *LikeCommands) DeleteLike(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/like/queries.go b/internal/app/like/queries.go new file mode 100644 index 0000000..113909d --- /dev/null +++ b/internal/app/like/queries.go @@ -0,0 +1,46 @@ +package like + +import ( + "context" + "tercul/internal/domain" +) + +// LikeQueries contains the query handlers for the like aggregate. +type LikeQueries struct { + repo domain.LikeRepository +} + +// NewLikeQueries creates a new LikeQueries handler. +func NewLikeQueries(repo domain.LikeRepository) *LikeQueries { + return &LikeQueries{repo: repo} +} + +// Like returns a like by ID. +func (q *LikeQueries) Like(ctx context.Context, id uint) (*domain.Like, error) { + return q.repo.GetByID(ctx, id) +} + +// LikesByUserID returns all likes for a user. +func (q *LikeQueries) LikesByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { + return q.repo.ListByUserID(ctx, userID) +} + +// LikesByWorkID returns all likes for a work. +func (q *LikeQueries) LikesByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { + return q.repo.ListByWorkID(ctx, workID) +} + +// LikesByTranslationID returns all likes for a translation. +func (q *LikeQueries) LikesByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { + return q.repo.ListByTranslationID(ctx, translationID) +} + +// LikesByCommentID returns all likes for a comment. +func (q *LikeQueries) LikesByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { + return q.repo.ListByCommentID(ctx, commentID) +} + +// Likes returns all likes. +func (q *LikeQueries) Likes(ctx context.Context) ([]domain.Like, error) { + return q.repo.ListAll(ctx) +} diff --git a/internal/app/like/service.go b/internal/app/like/service.go new file mode 100644 index 0000000..dec009b --- /dev/null +++ b/internal/app/like/service.go @@ -0,0 +1,17 @@ +package like + +import "tercul/internal/domain" + +// Service is the application service for the like aggregate. +type Service struct { + Commands *LikeCommands + Queries *LikeQueries +} + +// NewService creates a new like Service. +func NewService(repo domain.LikeRepository) *Service { + return &Service{ + Commands: NewLikeCommands(repo), + Queries: NewLikeQueries(repo), + } +} diff --git a/internal/app/localization/service.go b/internal/app/localization/service.go index 9e1a428..b57478d 100644 --- a/internal/app/localization/service.go +++ b/internal/app/localization/service.go @@ -2,82 +2,25 @@ package localization import ( "context" - "errors" "tercul/internal/domain" ) -// Service resolves localized attributes using translations -type Service interface { - GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) - GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) +// Service handles localization-related operations. +type Service struct { + repo domain.LocalizationRepository } -type service struct { - translationRepo domain.TranslationRepository +// NewService creates a new localization service. +func NewService(repo domain.LocalizationRepository) *Service { + return &Service{repo: repo} } -func NewService(translationRepo domain.TranslationRepository) Service { - return &service{translationRepo: translationRepo} +// GetTranslation returns a translation for a given key and language. +func (s *Service) GetTranslation(ctx context.Context, key string, language string) (string, error) { + return s.repo.GetTranslation(ctx, key, language) } -func (s *service) GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) { - if workID == 0 { - return "", errors.New("invalid work ID") - } - translations, err := s.translationRepo.ListByWorkID(ctx, workID) - if err != nil { - return "", err - } - return pickContent(translations, preferredLanguage), nil -} - -func (s *service) GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) { - if authorID == 0 { - return "", errors.New("invalid author ID") - } - translations, err := s.translationRepo.ListByEntity(ctx, "Author", authorID) - if err != nil { - return "", err - } - // Prefer Description from Translation as biography proxy - var byLang *domain.Translation - for i := range translations { - tr := &translations[i] - if tr.IsOriginalLanguage && tr.Description != "" { - return tr.Description, nil - } - if tr.Language == preferredLanguage && byLang == nil && tr.Description != "" { - byLang = tr - } - } - if byLang != nil { - return byLang.Description, nil - } - // fallback to any non-empty description - for i := range translations { - if translations[i].Description != "" { - return translations[i].Description, nil - } - } - return "", nil -} - -func pickContent(translations []domain.Translation, preferredLanguage string) string { - var byLang *domain.Translation - for i := range translations { - tr := &translations[i] - if tr.IsOriginalLanguage { - return tr.Content - } - if tr.Language == preferredLanguage && byLang == nil { - byLang = tr - } - } - if byLang != nil { - return byLang.Content - } - if len(translations) > 0 { - return translations[0].Content - } - return "" +// GetTranslations returns a map of translations for a given set of keys and language. +func (s *Service) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { + return s.repo.GetTranslations(ctx, keys, language) } diff --git a/internal/app/localization/service_test.go b/internal/app/localization/service_test.go new file mode 100644 index 0000000..1a1c3f0 --- /dev/null +++ b/internal/app/localization/service_test.go @@ -0,0 +1,65 @@ +package localization + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockLocalizationRepository struct { + mock.Mock +} + +func (m *mockLocalizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) { + args := m.Called(ctx, key, language) + return args.String(0), args.Error(1) +} + +func (m *mockLocalizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { + args := m.Called(ctx, keys, language) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]string), args.Error(1) +} + +func TestLocalizationService_GetTranslation(t *testing.T) { + repo := new(mockLocalizationRepository) + service := NewService(repo) + + ctx := context.Background() + key := "test_key" + language := "en" + expectedTranslation := "Test Translation" + + repo.On("GetTranslation", ctx, key, language).Return(expectedTranslation, nil) + + translation, err := service.GetTranslation(ctx, key, language) + + assert.NoError(t, err) + assert.Equal(t, expectedTranslation, translation) + repo.AssertExpectations(t) +} + +func TestLocalizationService_GetTranslations(t *testing.T) { + repo := new(mockLocalizationRepository) + service := NewService(repo) + + ctx := context.Background() + keys := []string{"key1", "key2"} + language := "en" + expectedTranslations := map[string]string{ + "key1": "Translation 1", + "key2": "Translation 2", + } + + repo.On("GetTranslations", ctx, keys, language).Return(expectedTranslations, nil) + + translations, err := service.GetTranslations(ctx, keys, language) + + assert.NoError(t, err) + assert.Equal(t, expectedTranslations, translations) + repo.AssertExpectations(t) +} diff --git a/internal/app/monetization/commands.go b/internal/app/monetization/commands.go new file mode 100644 index 0000000..4b5405b --- /dev/null +++ b/internal/app/monetization/commands.go @@ -0,0 +1,100 @@ +package monetization + +import ( + "context" + "errors" + "tercul/internal/domain" + "tercul/internal/platform/log" +) + +// MonetizationCommands contains the command handlers for monetization. +type MonetizationCommands struct { + repo domain.MonetizationRepository +} + +// NewMonetizationCommands creates a new MonetizationCommands handler. +func NewMonetizationCommands(repo domain.MonetizationRepository) *MonetizationCommands { + return &MonetizationCommands{repo: repo} +} + +// AddMonetizationToWork adds a monetization to a work. +func (c *MonetizationCommands) AddMonetizationToWork(ctx context.Context, workID uint, monetizationID uint) error { + if workID == 0 || monetizationID == 0 { + return errors.New("invalid work ID or monetization ID") + } + log.LogDebug("Adding monetization to work", log.F("work_id", workID), log.F("monetization_id", monetizationID)) + return c.repo.AddMonetizationToWork(ctx, workID, monetizationID) +} + +// RemoveMonetizationFromWork removes a monetization from a work. +func (c *MonetizationCommands) RemoveMonetizationFromWork(ctx context.Context, workID uint, monetizationID uint) error { + if workID == 0 || monetizationID == 0 { + return errors.New("invalid work ID or monetization ID") + } + log.LogDebug("Removing monetization from work", log.F("work_id", workID), log.F("monetization_id", monetizationID)) + return c.repo.RemoveMonetizationFromWork(ctx, workID, monetizationID) +} + +func (c *MonetizationCommands) AddMonetizationToAuthor(ctx context.Context, authorID uint, monetizationID uint) error { + if authorID == 0 || monetizationID == 0 { + return errors.New("invalid author ID or monetization ID") + } + log.LogDebug("Adding monetization to author", log.F("author_id", authorID), log.F("monetization_id", monetizationID)) + return c.repo.AddMonetizationToAuthor(ctx, authorID, monetizationID) +} + +func (c *MonetizationCommands) RemoveMonetizationFromAuthor(ctx context.Context, authorID uint, monetizationID uint) error { + if authorID == 0 || monetizationID == 0 { + return errors.New("invalid author ID or monetization ID") + } + log.LogDebug("Removing monetization from author", log.F("author_id", authorID), log.F("monetization_id", monetizationID)) + return c.repo.RemoveMonetizationFromAuthor(ctx, authorID, monetizationID) +} + +func (c *MonetizationCommands) AddMonetizationToBook(ctx context.Context, bookID uint, monetizationID uint) error { + if bookID == 0 || monetizationID == 0 { + return errors.New("invalid book ID or monetization ID") + } + log.LogDebug("Adding monetization to book", log.F("book_id", bookID), log.F("monetization_id", monetizationID)) + return c.repo.AddMonetizationToBook(ctx, bookID, monetizationID) +} + +func (c *MonetizationCommands) RemoveMonetizationFromBook(ctx context.Context, bookID uint, monetizationID uint) error { + if bookID == 0 || monetizationID == 0 { + return errors.New("invalid book ID or monetization ID") + } + log.LogDebug("Removing monetization from book", log.F("book_id", bookID), log.F("monetization_id", monetizationID)) + return c.repo.RemoveMonetizationFromBook(ctx, bookID, monetizationID) +} + +func (c *MonetizationCommands) AddMonetizationToPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { + if publisherID == 0 || monetizationID == 0 { + return errors.New("invalid publisher ID or monetization ID") + } + log.LogDebug("Adding monetization to publisher", log.F("publisher_id", publisherID), log.F("monetization_id", monetizationID)) + return c.repo.AddMonetizationToPublisher(ctx, publisherID, monetizationID) +} + +func (c *MonetizationCommands) RemoveMonetizationFromPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { + if publisherID == 0 || monetizationID == 0 { + return errors.New("invalid publisher ID or monetization ID") + } + log.LogDebug("Removing monetization from publisher", log.F("publisher_id", publisherID), log.F("monetization_id", monetizationID)) + return c.repo.RemoveMonetizationFromPublisher(ctx, publisherID, monetizationID) +} + +func (c *MonetizationCommands) AddMonetizationToSource(ctx context.Context, sourceID uint, monetizationID uint) error { + if sourceID == 0 || monetizationID == 0 { + return errors.New("invalid source ID or monetization ID") + } + log.LogDebug("Adding monetization to source", log.F("source_id", sourceID), log.F("monetization_id", monetizationID)) + return c.repo.AddMonetizationToSource(ctx, sourceID, monetizationID) +} + +func (c *MonetizationCommands) RemoveMonetizationFromSource(ctx context.Context, sourceID uint, monetizationID uint) error { + if sourceID == 0 || monetizationID == 0 { + return errors.New("invalid source ID or monetization ID") + } + log.LogDebug("Removing monetization from source", log.F("source_id", sourceID), log.F("monetization_id", monetizationID)) + return c.repo.RemoveMonetizationFromSource(ctx, sourceID, monetizationID) +} diff --git a/internal/app/monetization/commands_integration_test.go b/internal/app/monetization/commands_integration_test.go new file mode 100644 index 0000000..188886c --- /dev/null +++ b/internal/app/monetization/commands_integration_test.go @@ -0,0 +1,217 @@ +//go:build integration + +package monetization_test + +import ( + "context" + "testing" + "tercul/internal/app/monetization" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type MonetizationCommandsTestSuite struct { + testutil.IntegrationTestSuite + commands *monetization.MonetizationCommands +} + +func (s *MonetizationCommandsTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.commands = monetization.NewMonetizationCommands(s.MonetizationRepo) +} + +func (s *MonetizationCommandsTestSuite) TestAddMonetizationToWork() { + s.Run("should add a monetization to a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + + // Act + err := s.commands.AddMonetizationToWork(context.Background(), work.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + + // Verify that the association was created in the database + var foundWork domain.Work + err = s.DB.Preload("Monetizations").First(&foundWork, work.ID).Error + s.Require().NoError(err) + s.Require().Len(foundWork.Monetizations, 1) + s.Equal(monetization.ID, foundWork.Monetizations[0].ID) + }) +} + +func (s *MonetizationCommandsTestSuite) TestAddMonetizationToAuthor() { + s.Run("should add a monetization to an author", func() { + // Arrange + author := &domain.Author{Name: "Test Author"} + s.Require().NoError(s.AuthorRepo.Create(context.Background(), author)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + + // Act + err := s.commands.AddMonetizationToAuthor(context.Background(), author.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundAuthor domain.Author + err = s.DB.Preload("Monetizations").First(&foundAuthor, author.ID).Error + s.Require().NoError(err) + s.Require().Len(foundAuthor.Monetizations, 1) + s.Equal(monetization.ID, foundAuthor.Monetizations[0].ID) + }) +} + +func (s *MonetizationCommandsTestSuite) TestRemoveMonetizationFromAuthor() { + s.Run("should remove a monetization from an author", func() { + // Arrange + author := &domain.Author{Name: "Test Author"} + s.Require().NoError(s.AuthorRepo.Create(context.Background(), author)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + s.Require().NoError(s.commands.AddMonetizationToAuthor(context.Background(), author.ID, monetization.ID)) + + // Act + err := s.commands.RemoveMonetizationFromAuthor(context.Background(), author.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundAuthor domain.Author + err = s.DB.Preload("Monetizations").First(&foundAuthor, author.ID).Error + s.Require().NoError(err) + s.Require().Len(foundAuthor.Monetizations, 0) + }) +} + +func (s *MonetizationCommandsTestSuite) TestAddMonetizationToBook() { + s.Run("should add a monetization to a book", func() { + // Arrange + book := &domain.Book{Title: "Test Book"} + s.Require().NoError(s.BookRepo.Create(context.Background(), book)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + + // Act + err := s.commands.AddMonetizationToBook(context.Background(), book.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundBook domain.Book + err = s.DB.Preload("Monetizations").First(&foundBook, book.ID).Error + s.Require().NoError(err) + s.Require().Len(foundBook.Monetizations, 1) + s.Equal(monetization.ID, foundBook.Monetizations[0].ID) + }) +} + +func (s *MonetizationCommandsTestSuite) TestRemoveMonetizationFromBook() { + s.Run("should remove a monetization from a book", func() { + // Arrange + book := &domain.Book{Title: "Test Book"} + s.Require().NoError(s.BookRepo.Create(context.Background(), book)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + s.Require().NoError(s.commands.AddMonetizationToBook(context.Background(), book.ID, monetization.ID)) + + // Act + err := s.commands.RemoveMonetizationFromBook(context.Background(), book.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundBook domain.Book + err = s.DB.Preload("Monetizations").First(&foundBook, book.ID).Error + s.Require().NoError(err) + s.Require().Len(foundBook.Monetizations, 0) + }) +} + +func (s *MonetizationCommandsTestSuite) TestAddMonetizationToPublisher() { + s.Run("should add a monetization to a publisher", func() { + // Arrange + publisher := &domain.Publisher{Name: "Test Publisher"} + s.Require().NoError(s.PublisherRepo.Create(context.Background(), publisher)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + + // Act + err := s.commands.AddMonetizationToPublisher(context.Background(), publisher.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundPublisher domain.Publisher + err = s.DB.Preload("Monetizations").First(&foundPublisher, publisher.ID).Error + s.Require().NoError(err) + s.Require().Len(foundPublisher.Monetizations, 1) + s.Equal(monetization.ID, foundPublisher.Monetizations[0].ID) + }) +} + +func (s *MonetizationCommandsTestSuite) TestRemoveMonetizationFromPublisher() { + s.Run("should remove a monetization from a publisher", func() { + // Arrange + publisher := &domain.Publisher{Name: "Test Publisher"} + s.Require().NoError(s.PublisherRepo.Create(context.Background(), publisher)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + s.Require().NoError(s.commands.AddMonetizationToPublisher(context.Background(), publisher.ID, monetization.ID)) + + // Act + err := s.commands.RemoveMonetizationFromPublisher(context.Background(), publisher.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundPublisher domain.Publisher + err = s.DB.Preload("Monetizations").First(&foundPublisher, publisher.ID).Error + s.Require().NoError(err) + s.Require().Len(foundPublisher.Monetizations, 0) + }) +} + +func (s *MonetizationCommandsTestSuite) TestAddMonetizationToSource() { + s.Run("should add a monetization to a source", func() { + // Arrange + source := &domain.Source{Name: "Test Source"} + s.Require().NoError(s.SourceRepo.Create(context.Background(), source)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + + // Act + err := s.commands.AddMonetizationToSource(context.Background(), source.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundSource domain.Source + err = s.DB.Preload("Monetizations").First(&foundSource, source.ID).Error + s.Require().NoError(err) + s.Require().Len(foundSource.Monetizations, 1) + s.Equal(monetization.ID, foundSource.Monetizations[0].ID) + }) +} + +func (s *MonetizationCommandsTestSuite) TestRemoveMonetizationFromSource() { + s.Run("should remove a monetization from a source", func() { + // Arrange + source := &domain.Source{Name: "Test Source"} + s.Require().NoError(s.SourceRepo.Create(context.Background(), source)) + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + s.Require().NoError(s.commands.AddMonetizationToSource(context.Background(), source.ID, monetization.ID)) + + // Act + err := s.commands.RemoveMonetizationFromSource(context.Background(), source.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + var foundSource domain.Source + err = s.DB.Preload("Monetizations").First(&foundSource, source.ID).Error + s.Require().NoError(err) + s.Require().Len(foundSource.Monetizations, 0) + }) +} + +func TestMonetizationCommands(t *testing.T) { + suite.Run(t, new(MonetizationCommandsTestSuite)) +} diff --git a/internal/app/monetization/commands_test.go b/internal/app/monetization/commands_test.go new file mode 100644 index 0000000..5e0adb0 --- /dev/null +++ b/internal/app/monetization/commands_test.go @@ -0,0 +1,206 @@ +package monetization + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "testing" +) + +type MonetizationCommandsSuite struct { + suite.Suite + repo *mockMonetizationRepository + commands *MonetizationCommands +} + +func (s *MonetizationCommandsSuite) SetupTest() { + s.repo = &mockMonetizationRepository{} + s.commands = NewMonetizationCommands(s.repo) +} + +func TestMonetizationCommandsSuite(t *testing.T) { + suite.Run(t, new(MonetizationCommandsSuite)) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToWork_Success() { + err := s.commands.AddMonetizationToWork(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToWork_ZeroID() { + err := s.commands.AddMonetizationToWork(context.Background(), 0, 2) + assert.Error(s.T(), err) + err = s.commands.AddMonetizationToWork(context.Background(), 1, 0) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToWork_RepoError() { + s.repo.addMonetizationToWorkFunc = func(ctx context.Context, workID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.AddMonetizationToWork(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromWork_Success() { + err := s.commands.RemoveMonetizationFromWork(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromWork_ZeroID() { + err := s.commands.RemoveMonetizationFromWork(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromWork_RepoError() { + s.repo.removeMonetizationFromWorkFunc = func(ctx context.Context, workID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveMonetizationFromWork(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToAuthor_Success() { + err := s.commands.AddMonetizationToAuthor(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToAuthor_ZeroID() { + err := s.commands.AddMonetizationToAuthor(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToAuthor_RepoError() { + s.repo.addMonetizationToAuthorFunc = func(ctx context.Context, authorID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.AddMonetizationToAuthor(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromAuthor_Success() { + err := s.commands.RemoveMonetizationFromAuthor(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromAuthor_ZeroID() { + err := s.commands.RemoveMonetizationFromAuthor(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromAuthor_RepoError() { + s.repo.removeMonetizationFromAuthorFunc = func(ctx context.Context, authorID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveMonetizationFromAuthor(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToBook_Success() { + err := s.commands.AddMonetizationToBook(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToBook_ZeroID() { + err := s.commands.AddMonetizationToBook(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToBook_RepoError() { + s.repo.addMonetizationToBookFunc = func(ctx context.Context, bookID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.AddMonetizationToBook(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromBook_Success() { + err := s.commands.RemoveMonetizationFromBook(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromBook_ZeroID() { + err := s.commands.RemoveMonetizationFromBook(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromBook_RepoError() { + s.repo.removeMonetizationFromBookFunc = func(ctx context.Context, bookID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveMonetizationFromBook(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToPublisher_Success() { + err := s.commands.AddMonetizationToPublisher(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToPublisher_ZeroID() { + err := s.commands.AddMonetizationToPublisher(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToPublisher_RepoError() { + s.repo.addMonetizationToPublisherFunc = func(ctx context.Context, publisherID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.AddMonetizationToPublisher(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromPublisher_Success() { + err := s.commands.RemoveMonetizationFromPublisher(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromPublisher_ZeroID() { + err := s.commands.RemoveMonetizationFromPublisher(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromPublisher_RepoError() { + s.repo.removeMonetizationFromPublisherFunc = func(ctx context.Context, publisherID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveMonetizationFromPublisher(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToSource_Success() { + err := s.commands.AddMonetizationToSource(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToSource_ZeroID() { + err := s.commands.AddMonetizationToSource(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestAddMonetizationToSource_RepoError() { + s.repo.addMonetizationToSourceFunc = func(ctx context.Context, sourceID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.AddMonetizationToSource(context.Background(), 1, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromSource_Success() { + err := s.commands.RemoveMonetizationFromSource(context.Background(), 1, 2) + assert.NoError(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromSource_ZeroID() { + err := s.commands.RemoveMonetizationFromSource(context.Background(), 0, 2) + assert.Error(s.T(), err) +} + +func (s *MonetizationCommandsSuite) TestRemoveMonetizationFromSource_RepoError() { + s.repo.removeMonetizationFromSourceFunc = func(ctx context.Context, sourceID uint, monetizationID uint) error { + return errors.New("db error") + } + err := s.commands.RemoveMonetizationFromSource(context.Background(), 1, 2) + assert.Error(s.T(), err) +} diff --git a/internal/app/monetization/main_test.go b/internal/app/monetization/main_test.go new file mode 100644 index 0000000..2ebb668 --- /dev/null +++ b/internal/app/monetization/main_test.go @@ -0,0 +1,148 @@ +package monetization + +import ( + "context" + "tercul/internal/domain" +) + +type mockMonetizationRepository struct { + domain.MonetizationRepository + addMonetizationToWorkFunc func(ctx context.Context, workID uint, monetizationID uint) error + removeMonetizationFromWorkFunc func(ctx context.Context, workID uint, monetizationID uint) error + addMonetizationToAuthorFunc func(ctx context.Context, authorID uint, monetizationID uint) error + removeMonetizationFromAuthorFunc func(ctx context.Context, authorID uint, monetizationID uint) error + addMonetizationToBookFunc func(ctx context.Context, bookID uint, monetizationID uint) error + removeMonetizationFromBookFunc func(ctx context.Context, bookID uint, monetizationID uint) error + addMonetizationToPublisherFunc func(ctx context.Context, publisherID uint, monetizationID uint) error + removeMonetizationFromPublisherFunc func(ctx context.Context, publisherID uint, monetizationID uint) error + addMonetizationToSourceFunc func(ctx context.Context, sourceID uint, monetizationID uint) error + removeMonetizationFromSourceFunc func(ctx context.Context, sourceID uint, monetizationID uint) error + getByIDFunc func(ctx context.Context, id uint) (*domain.Monetization, error) + listAllFunc func(ctx context.Context) ([]domain.Monetization, error) +} + +func (m *mockMonetizationRepository) GetByID(ctx context.Context, id uint) (*domain.Monetization, error) { + if m.getByIDFunc != nil { + return m.getByIDFunc(ctx, id) + } + return nil, nil +} + +func (m *mockMonetizationRepository) ListAll(ctx context.Context) ([]domain.Monetization, error) { + if m.listAllFunc != nil { + return m.listAllFunc(ctx) + } + return nil, nil +} + +func (m *mockMonetizationRepository) AddMonetizationToWork(ctx context.Context, workID uint, monetizationID uint) error { + if m.addMonetizationToWorkFunc != nil { + return m.addMonetizationToWorkFunc(ctx, workID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) RemoveMonetizationFromWork(ctx context.Context, workID uint, monetizationID uint) error { + if m.removeMonetizationFromWorkFunc != nil { + return m.removeMonetizationFromWorkFunc(ctx, workID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) AddMonetizationToAuthor(ctx context.Context, authorID uint, monetizationID uint) error { + if m.addMonetizationToAuthorFunc != nil { + return m.addMonetizationToAuthorFunc(ctx, authorID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) RemoveMonetizationFromAuthor(ctx context.Context, authorID uint, monetizationID uint) error { + if m.removeMonetizationFromAuthorFunc != nil { + return m.removeMonetizationFromAuthorFunc(ctx, authorID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) AddMonetizationToBook(ctx context.Context, bookID uint, monetizationID uint) error { + if m.addMonetizationToBookFunc != nil { + return m.addMonetizationToBookFunc(ctx, bookID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) RemoveMonetizationFromBook(ctx context.Context, bookID uint, monetizationID uint) error { + if m.removeMonetizationFromBookFunc != nil { + return m.removeMonetizationFromBookFunc(ctx, bookID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) AddMonetizationToPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { + if m.addMonetizationToPublisherFunc != nil { + return m.addMonetizationToPublisherFunc(ctx, publisherID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) RemoveMonetizationFromPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { + if m.removeMonetizationFromPublisherFunc != nil { + return m.removeMonetizationFromPublisherFunc(ctx, publisherID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) AddMonetizationToSource(ctx context.Context, sourceID uint, monetizationID uint) error { + if m.addMonetizationToSourceFunc != nil { + return m.addMonetizationToSourceFunc(ctx, sourceID, monetizationID) + } + return nil +} +func (m *mockMonetizationRepository) RemoveMonetizationFromSource(ctx context.Context, sourceID uint, monetizationID uint) error { + if m.removeMonetizationFromSourceFunc != nil { + return m.removeMonetizationFromSourceFunc(ctx, sourceID, monetizationID) + } + return nil +} + +type mockWorkRepository struct { + domain.WorkRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) +} +func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockAuthorRepository struct { + domain.AuthorRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) +} +func (m *mockAuthorRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockBookRepository struct { + domain.BookRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) +} +func (m *mockBookRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockPublisherRepository struct { + domain.PublisherRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) +} +func (m *mockPublisherRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} +type mockSourceRepository struct { + domain.SourceRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) +} +func (m *mockSourceRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} diff --git a/internal/app/monetization/queries.go b/internal/app/monetization/queries.go new file mode 100644 index 0000000..4e1b410 --- /dev/null +++ b/internal/app/monetization/queries.go @@ -0,0 +1,83 @@ +package monetization + +import ( + "context" + "errors" + "tercul/internal/domain" + "tercul/internal/platform/log" +) + +// MonetizationQueries contains the query handlers for monetization. +type MonetizationQueries struct { + repo domain.MonetizationRepository + workRepo domain.WorkRepository + authorRepo domain.AuthorRepository + bookRepo domain.BookRepository + publisherRepo domain.PublisherRepository + sourceRepo domain.SourceRepository +} + +// NewMonetizationQueries creates a new MonetizationQueries handler. +func NewMonetizationQueries(repo domain.MonetizationRepository, workRepo domain.WorkRepository, authorRepo domain.AuthorRepository, bookRepo domain.BookRepository, publisherRepo domain.PublisherRepository, sourceRepo domain.SourceRepository) *MonetizationQueries { + return &MonetizationQueries{repo: repo, workRepo: workRepo, authorRepo: authorRepo, bookRepo: bookRepo, publisherRepo: publisherRepo, sourceRepo: sourceRepo} +} + +// GetMonetizationByID retrieves a monetization by ID. +func (q *MonetizationQueries) GetMonetizationByID(ctx context.Context, id uint) (*domain.Monetization, error) { + if id == 0 { + return nil, errors.New("invalid monetization ID") + } + log.LogDebug("Getting monetization by ID", log.F("id", id)) + return q.repo.GetByID(ctx, id) +} + +// ListMonetizations retrieves all monetizations. +func (q *MonetizationQueries) ListMonetizations(ctx context.Context) ([]domain.Monetization, error) { + log.LogDebug("Listing all monetizations") + return q.repo.ListAll(ctx) +} + +func (q *MonetizationQueries) GetMonetizationsForWork(ctx context.Context, workID uint) ([]*domain.Monetization, error) { + log.LogDebug("Getting monetizations for work", log.F("work_id", workID)) + work, err := q.workRepo.GetByIDWithOptions(ctx, workID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) + if err != nil { + return nil, err + } + return work.Monetizations, nil +} + +func (q *MonetizationQueries) GetMonetizationsForAuthor(ctx context.Context, authorID uint) ([]*domain.Monetization, error) { + log.LogDebug("Getting monetizations for author", log.F("author_id", authorID)) + author, err := q.authorRepo.GetByIDWithOptions(ctx, authorID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) + if err != nil { + return nil, err + } + return author.Monetizations, nil +} + +func (q *MonetizationQueries) GetMonetizationsForBook(ctx context.Context, bookID uint) ([]*domain.Monetization, error) { + log.LogDebug("Getting monetizations for book", log.F("book_id", bookID)) + book, err := q.bookRepo.GetByIDWithOptions(ctx, bookID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) + if err != nil { + return nil, err + } + return book.Monetizations, nil +} + +func (q *MonetizationQueries) GetMonetizationsForPublisher(ctx context.Context, publisherID uint) ([]*domain.Monetization, error) { + log.LogDebug("Getting monetizations for publisher", log.F("publisher_id", publisherID)) + publisher, err := q.publisherRepo.GetByIDWithOptions(ctx, publisherID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) + if err != nil { + return nil, err + } + return publisher.Monetizations, nil +} + +func (q *MonetizationQueries) GetMonetizationsForSource(ctx context.Context, sourceID uint) ([]*domain.Monetization, error) { + log.LogDebug("Getting monetizations for source", log.F("source_id", sourceID)) + source, err := q.sourceRepo.GetByIDWithOptions(ctx, sourceID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) + if err != nil { + return nil, err + } + return source.Monetizations, nil +} diff --git a/internal/app/monetization/queries_test.go b/internal/app/monetization/queries_test.go new file mode 100644 index 0000000..7ba483e --- /dev/null +++ b/internal/app/monetization/queries_test.go @@ -0,0 +1,175 @@ +package monetization + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "testing" +) + +type MonetizationQueriesSuite struct { + suite.Suite + repo *mockMonetizationRepository + workRepo *mockWorkRepository + authorRepo *mockAuthorRepository + bookRepo *mockBookRepository + publisherRepo *mockPublisherRepository + sourceRepo *mockSourceRepository + queries *MonetizationQueries +} + +func (s *MonetizationQueriesSuite) SetupTest() { + s.repo = &mockMonetizationRepository{} + s.workRepo = &mockWorkRepository{} + s.authorRepo = &mockAuthorRepository{} + s.bookRepo = &mockBookRepository{} + s.publisherRepo = &mockPublisherRepository{} + s.sourceRepo = &mockSourceRepository{} + s.queries = NewMonetizationQueries(s.repo, s.workRepo, s.authorRepo, s.bookRepo, s.publisherRepo, s.sourceRepo) +} + +func TestMonetizationQueriesSuite(t *testing.T) { + suite.Run(t, new(MonetizationQueriesSuite)) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationByID_Success() { + monetization := &domain.Monetization{Amount: 10.0} + monetization.ID = 1 + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Monetization, error) { + return monetization, nil + } + m, err := s.queries.GetMonetizationByID(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), monetization, m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationByID_ZeroID() { + m, err := s.queries.GetMonetizationByID(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationByID_RepoError() { + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Monetization, error) { + return nil, errors.New("db error") + } + m, err := s.queries.GetMonetizationByID(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} + +func (s *MonetizationQueriesSuite) TestListMonetizations_RepoError() { + s.repo.listAllFunc = func(ctx context.Context) ([]domain.Monetization, error) { + return nil, errors.New("db error") + } + m, err := s.queries.ListMonetizations(context.Background()) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} + +func (s *MonetizationQueriesSuite) TestListMonetizations_Success() { + monetizations := []domain.Monetization{{Amount: 10.0}} + s.repo.listAllFunc = func(ctx context.Context) ([]domain.Monetization, error) { + return monetizations, nil + } + m, err := s.queries.ListMonetizations(context.Background()) + assert.NoError(s.T(), err) + assert.Equal(s.T(), monetizations, m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForWork_Success() { + monetizations := []*domain.Monetization{{Amount: 10.0}} + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + return &domain.Work{Monetizations: monetizations}, nil + } + m, err := s.queries.GetMonetizationsForWork(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), monetizations, m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForWork_RepoError() { + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + return nil, errors.New("db error") + } + m, err := s.queries.GetMonetizationsForWork(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForAuthor_Success() { + monetizations := []*domain.Monetization{{Amount: 10.0}} + s.authorRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) { + return &domain.Author{Monetizations: monetizations}, nil + } + m, err := s.queries.GetMonetizationsForAuthor(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), monetizations, m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForAuthor_RepoError() { + s.authorRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) { + return nil, errors.New("db error") + } + m, err := s.queries.GetMonetizationsForAuthor(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForBook_Success() { + monetizations := []*domain.Monetization{{Amount: 10.0}} + s.bookRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) { + return &domain.Book{Monetizations: monetizations}, nil + } + m, err := s.queries.GetMonetizationsForBook(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), monetizations, m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForBook_RepoError() { + s.bookRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Book, error) { + return nil, errors.New("db error") + } + m, err := s.queries.GetMonetizationsForBook(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForPublisher_Success() { + monetizations := []*domain.Monetization{{Amount: 10.0}} + s.publisherRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) { + return &domain.Publisher{Monetizations: monetizations}, nil + } + m, err := s.queries.GetMonetizationsForPublisher(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), monetizations, m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForPublisher_RepoError() { + s.publisherRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Publisher, error) { + return nil, errors.New("db error") + } + m, err := s.queries.GetMonetizationsForPublisher(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForSource_Success() { + monetizations := []*domain.Monetization{{Amount: 10.0}} + s.sourceRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) { + return &domain.Source{Monetizations: monetizations}, nil + } + m, err := s.queries.GetMonetizationsForSource(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), monetizations, m) +} + +func (s *MonetizationQueriesSuite) TestGetMonetizationsForSource_RepoError() { + s.sourceRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Source, error) { + return nil, errors.New("db error") + } + m, err := s.queries.GetMonetizationsForSource(context.Background(), 1) + assert.Error(s.T(), err) + assert.Nil(s.T(), m) +} diff --git a/internal/app/search/service.go b/internal/app/search/service.go index 17440d8..db86847 100644 --- a/internal/app/search/service.go +++ b/internal/app/search/service.go @@ -3,9 +3,9 @@ package search import ( "context" "fmt" - "log" "tercul/internal/app/localization" "tercul/internal/domain" + "tercul/internal/platform/log" "tercul/internal/platform/search" ) @@ -15,40 +15,31 @@ type IndexService interface { } type indexService struct { - localization localization.Service - translations domain.TranslationRepository + localization *localization.Service + weaviate search.WeaviateWrapper } -func NewIndexService(localization localization.Service, translations domain.TranslationRepository) IndexService { - return &indexService{localization: localization, translations: translations} +func NewIndexService(localization *localization.Service, weaviate search.WeaviateWrapper) IndexService { + return &indexService{localization: localization, weaviate: weaviate} } func (s *indexService) IndexWork(ctx context.Context, work domain.Work) error { + log.LogDebug("Indexing work", log.F("work_id", work.ID)) + // TODO: Get content from translation service + content := "" // Choose best content snapshot for indexing - content, err := s.localization.GetWorkContent(ctx, work.ID, work.Language) + // content, err := s.localization.GetWorkContent(ctx, work.ID, work.Language) + // if err != nil { + // log.LogError("Failed to get work content for indexing", log.F("work_id", work.ID), log.F("error", err)) + // return err + // } + + err := s.weaviate.IndexWork(ctx, &work, content) if err != nil { + log.LogError("Failed to index work in Weaviate", log.F("work_id", work.ID), log.F("error", err)) return err } - - props := map[string]interface{}{ - "language": work.Language, - "title": work.Title, - "description": work.Description, - "status": work.Status, - } - if content != "" { - props["content"] = content - } - - _, wErr := search.Client.Data().Creator(). - WithClassName("Work"). - WithID(formatID(work.ID)). - WithProperties(props). - Do(ctx) - if wErr != nil { - log.Printf("weaviate index error: %v", wErr) - return wErr - } + log.LogInfo("Successfully indexed work", log.F("work_id", work.ID)) return nil } diff --git a/internal/app/search/service_test.go b/internal/app/search/service_test.go new file mode 100644 index 0000000..b293c72 --- /dev/null +++ b/internal/app/search/service_test.go @@ -0,0 +1,62 @@ +package search + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "tercul/internal/app/localization" + "tercul/internal/domain" +) + +type mockLocalizationRepository struct { + mock.Mock +} + +func (m *mockLocalizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) { + args := m.Called(ctx, key, language) + return args.String(0), args.Error(1) +} + +func (m *mockLocalizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { + args := m.Called(ctx, keys, language) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]string), args.Error(1) +} + +type mockWeaviateWrapper struct { + mock.Mock +} + +func (m *mockWeaviateWrapper) IndexWork(ctx context.Context, work *domain.Work, content string) error { + args := m.Called(ctx, work, content) + return args.Error(0) +} + +func TestIndexService_IndexWork(t *testing.T) { + localizationRepo := new(mockLocalizationRepository) + localizationService := localization.NewService(localizationRepo) + weaviateWrapper := new(mockWeaviateWrapper) + service := NewIndexService(localizationService, weaviateWrapper) + + ctx := context.Background() + work := domain.Work{ + TranslatableModel: domain.TranslatableModel{ + BaseModel: domain.BaseModel{ID: 1}, + Language: "en", + }, + Title: "Test Work", + } + + // localizationRepo.On("GetTranslation", ctx, "work:1:content", "en").Return("Test content", nil) + weaviateWrapper.On("IndexWork", ctx, &work, "").Return(nil) + + err := service.IndexWork(ctx, work) + + assert.NoError(t, err) + // localizationRepo.AssertExpectations(t) + weaviateWrapper.AssertExpectations(t) +} diff --git a/internal/app/server_factory.go b/internal/app/server_factory.go deleted file mode 100644 index 5339a80..0000000 --- a/internal/app/server_factory.go +++ /dev/null @@ -1,80 +0,0 @@ -package app - -import ( - "tercul/internal/jobs/linguistics" - syncjob "tercul/internal/jobs/sync" - "tercul/internal/platform/config" - "tercul/internal/platform/log" - - "github.com/hibiken/asynq" -) - -// ServerFactory handles the creation of HTTP and background job servers -type ServerFactory struct { - appBuilder *ApplicationBuilder -} - -// NewServerFactory creates a new ServerFactory -func NewServerFactory(appBuilder *ApplicationBuilder) *ServerFactory { - return &ServerFactory{ - appBuilder: appBuilder, - } -} - - -// CreateBackgroundJobServers creates and configures background job servers -func (f *ServerFactory) CreateBackgroundJobServers() ([]*asynq.Server, error) { - log.LogInfo("Setting up background job servers") - - redisOpt := asynq.RedisClientOpt{ - Addr: config.Cfg.RedisAddr, - Password: config.Cfg.RedisPassword, - DB: config.Cfg.RedisDB, - } - - var servers []*asynq.Server - - // Setup data synchronization server - log.LogInfo("Setting up data synchronization server", - log.F("concurrency", config.Cfg.MaxRetries)) - - syncServer := asynq.NewServer(redisOpt, asynq.Config{Concurrency: config.Cfg.MaxRetries}) - - // Create sync job instance - syncJobInstance := syncjob.NewSyncJob( - f.appBuilder.GetDB(), - f.appBuilder.GetAsynq(), - ) - - // Register sync job handlers - syncjob.RegisterQueueHandlers(syncServer, syncJobInstance) - servers = append(servers, syncServer) - - // Setup linguistic analysis server - log.LogInfo("Setting up linguistic analysis server", - log.F("concurrency", config.Cfg.MaxRetries)) - - // Create linguistic sync job - linguisticSyncJob := linguistics.NewLinguisticSyncJob( - f.appBuilder.GetDB(), - f.appBuilder.GetLinguisticsFactory().GetAnalyzer(), - f.appBuilder.GetAsynq(), - ) - - // Create linguistic server and register handlers - linguisticServer := asynq.NewServer(redisOpt, asynq.Config{Concurrency: config.Cfg.MaxRetries}) - - // Register linguistic handlers - linguisticMux := asynq.NewServeMux() - linguistics.RegisterLinguisticHandlers(linguisticMux, linguisticSyncJob) - - // For now, we'll need to run the server with the mux when it's started - // This is a temporary workaround - in production, you'd want to properly configure the server - servers = append(servers, linguisticServer) - - log.LogInfo("Background job servers created successfully", - log.F("serverCount", len(servers))) - - return servers, nil -} - diff --git a/internal/app/tag/commands.go b/internal/app/tag/commands.go new file mode 100644 index 0000000..d82ebe1 --- /dev/null +++ b/internal/app/tag/commands.go @@ -0,0 +1,62 @@ +package tag + +import ( + "context" + "tercul/internal/domain" +) + +// TagCommands contains the command handlers for the tag aggregate. +type TagCommands struct { + repo domain.TagRepository +} + +// NewTagCommands creates a new TagCommands handler. +func NewTagCommands(repo domain.TagRepository) *TagCommands { + return &TagCommands{repo: repo} +} + +// CreateTagInput represents the input for creating a new tag. +type CreateTagInput struct { + Name string + Description string +} + +// CreateTag creates a new tag. +func (c *TagCommands) CreateTag(ctx context.Context, input CreateTagInput) (*domain.Tag, error) { + tag := &domain.Tag{ + Name: input.Name, + Description: input.Description, + } + err := c.repo.Create(ctx, tag) + if err != nil { + return nil, err + } + return tag, nil +} + +// UpdateTagInput represents the input for updating an existing tag. +type UpdateTagInput struct { + ID uint + Name string + Description string +} + +// UpdateTag updates an existing tag. +func (c *TagCommands) UpdateTag(ctx context.Context, input UpdateTagInput) (*domain.Tag, error) { + tag, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + tag.Name = input.Name + tag.Description = input.Description + err = c.repo.Update(ctx, tag) + if err != nil { + return nil, err + } + return tag, nil +} + +// DeleteTag deletes a tag by ID. +func (c *TagCommands) DeleteTag(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/tag/queries.go b/internal/app/tag/queries.go new file mode 100644 index 0000000..eeee5e1 --- /dev/null +++ b/internal/app/tag/queries.go @@ -0,0 +1,36 @@ +package tag + +import ( + "context" + "tercul/internal/domain" +) + +// TagQueries contains the query handlers for the tag aggregate. +type TagQueries struct { + repo domain.TagRepository +} + +// NewTagQueries creates a new TagQueries handler. +func NewTagQueries(repo domain.TagRepository) *TagQueries { + return &TagQueries{repo: repo} +} + +// Tag returns a tag by ID. +func (q *TagQueries) Tag(ctx context.Context, id uint) (*domain.Tag, error) { + return q.repo.GetByID(ctx, id) +} + +// TagByName returns a tag by name. +func (q *TagQueries) TagByName(ctx context.Context, name string) (*domain.Tag, error) { + return q.repo.FindByName(ctx, name) +} + +// TagsByWorkID returns all tags for a work. +func (q *TagQueries) TagsByWorkID(ctx context.Context, workID uint) ([]domain.Tag, error) { + return q.repo.ListByWorkID(ctx, workID) +} + +// Tags returns all tags. +func (q *TagQueries) Tags(ctx context.Context) ([]domain.Tag, error) { + return q.repo.ListAll(ctx) +} diff --git a/internal/app/tag/service.go b/internal/app/tag/service.go new file mode 100644 index 0000000..bd51338 --- /dev/null +++ b/internal/app/tag/service.go @@ -0,0 +1,17 @@ +package tag + +import "tercul/internal/domain" + +// Service is the application service for the tag aggregate. +type Service struct { + Commands *TagCommands + Queries *TagQueries +} + +// NewService creates a new tag Service. +func NewService(repo domain.TagRepository) *Service { + return &Service{ + Commands: NewTagCommands(repo), + Queries: NewTagQueries(repo), + } +} diff --git a/internal/app/translation/commands.go b/internal/app/translation/commands.go new file mode 100644 index 0000000..ffb68c2 --- /dev/null +++ b/internal/app/translation/commands.go @@ -0,0 +1,80 @@ +package translation + +import ( + "context" + "tercul/internal/domain" +) + +// TranslationCommands contains the command handlers for the translation aggregate. +type TranslationCommands struct { + repo domain.TranslationRepository +} + +// NewTranslationCommands creates a new TranslationCommands handler. +func NewTranslationCommands(repo domain.TranslationRepository) *TranslationCommands { + return &TranslationCommands{repo: repo} +} + +// CreateTranslationInput represents the input for creating a new translation. +type CreateTranslationInput struct { + Title string + Content string + Description string + Language string + Status domain.TranslationStatus + TranslatableID uint + TranslatableType string + TranslatorID *uint +} + +// CreateTranslation creates a new translation. +func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) { + translation := &domain.Translation{ + Title: input.Title, + Content: input.Content, + Description: input.Description, + Language: input.Language, + Status: input.Status, + TranslatableID: input.TranslatableID, + TranslatableType: input.TranslatableType, + TranslatorID: input.TranslatorID, + } + err := c.repo.Create(ctx, translation) + if err != nil { + return nil, err + } + return translation, nil +} + +// UpdateTranslationInput represents the input for updating an existing translation. +type UpdateTranslationInput struct { + ID uint + Title string + Content string + Description string + Language string + Status domain.TranslationStatus +} + +// UpdateTranslation updates an existing translation. +func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input UpdateTranslationInput) (*domain.Translation, error) { + translation, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + translation.Title = input.Title + translation.Content = input.Content + translation.Description = input.Description + translation.Language = input.Language + translation.Status = input.Status + err = c.repo.Update(ctx, translation) + if err != nil { + return nil, err + } + return translation, nil +} + +// DeleteTranslation deletes a translation by ID. +func (c *TranslationCommands) DeleteTranslation(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/translation/queries.go b/internal/app/translation/queries.go new file mode 100644 index 0000000..0fbb0cb --- /dev/null +++ b/internal/app/translation/queries.go @@ -0,0 +1,46 @@ +package translation + +import ( + "context" + "tercul/internal/domain" +) + +// TranslationQueries contains the query handlers for the translation aggregate. +type TranslationQueries struct { + repo domain.TranslationRepository +} + +// NewTranslationQueries creates a new TranslationQueries handler. +func NewTranslationQueries(repo domain.TranslationRepository) *TranslationQueries { + return &TranslationQueries{repo: repo} +} + +// Translation returns a translation by ID. +func (q *TranslationQueries) Translation(ctx context.Context, id uint) (*domain.Translation, error) { + return q.repo.GetByID(ctx, id) +} + +// TranslationsByWorkID returns all translations for a work. +func (q *TranslationQueries) TranslationsByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) { + return q.repo.ListByWorkID(ctx, workID) +} + +// TranslationsByEntity returns all translations for an entity. +func (q *TranslationQueries) TranslationsByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) { + return q.repo.ListByEntity(ctx, entityType, entityID) +} + +// TranslationsByTranslatorID returns all translations for a translator. +func (q *TranslationQueries) TranslationsByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) { + return q.repo.ListByTranslatorID(ctx, translatorID) +} + +// TranslationsByStatus returns all translations for a status. +func (q *TranslationQueries) TranslationsByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) { + return q.repo.ListByStatus(ctx, status) +} + +// Translations returns all translations. +func (q *TranslationQueries) Translations(ctx context.Context) ([]domain.Translation, error) { + return q.repo.ListAll(ctx) +} diff --git a/internal/app/translation/service.go b/internal/app/translation/service.go new file mode 100644 index 0000000..5183a9c --- /dev/null +++ b/internal/app/translation/service.go @@ -0,0 +1,17 @@ +package translation + +import "tercul/internal/domain" + +// Service is the application service for the translation aggregate. +type Service struct { + Commands *TranslationCommands + Queries *TranslationQueries +} + +// NewService creates a new translation Service. +func NewService(repo domain.TranslationRepository) *Service { + return &Service{ + Commands: NewTranslationCommands(repo), + Queries: NewTranslationQueries(repo), + } +} diff --git a/internal/app/user/commands.go b/internal/app/user/commands.go new file mode 100644 index 0000000..87f5232 --- /dev/null +++ b/internal/app/user/commands.go @@ -0,0 +1,76 @@ +package user + +import ( + "context" + "tercul/internal/domain" +) + +// UserCommands contains the command handlers for the user aggregate. +type UserCommands struct { + repo domain.UserRepository +} + +// NewUserCommands creates a new UserCommands handler. +func NewUserCommands(repo domain.UserRepository) *UserCommands { + return &UserCommands{repo: repo} +} + +// CreateUserInput represents the input for creating a new user. +type CreateUserInput struct { + Username string + Email string + Password string + FirstName string + LastName string + Role domain.UserRole +} + +// CreateUser creates a new user. +func (c *UserCommands) CreateUser(ctx context.Context, input CreateUserInput) (*domain.User, error) { + user := &domain.User{ + Username: input.Username, + Email: input.Email, + Password: input.Password, + FirstName: input.FirstName, + LastName: input.LastName, + Role: input.Role, + } + err := c.repo.Create(ctx, user) + if err != nil { + return nil, err + } + return user, nil +} + +// UpdateUserInput represents the input for updating an existing user. +type UpdateUserInput struct { + ID uint + Username string + Email string + FirstName string + LastName string + Role domain.UserRole +} + +// UpdateUser updates an existing user. +func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*domain.User, error) { + user, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + user.Username = input.Username + user.Email = input.Email + user.FirstName = input.FirstName + user.LastName = input.LastName + user.Role = input.Role + err = c.repo.Update(ctx, user) + if err != nil { + return nil, err + } + return user, nil +} + +// DeleteUser deletes a user by ID. +func (c *UserCommands) DeleteUser(ctx context.Context, id uint) error { + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/user/queries.go b/internal/app/user/queries.go new file mode 100644 index 0000000..f161c58 --- /dev/null +++ b/internal/app/user/queries.go @@ -0,0 +1,41 @@ +package user + +import ( + "context" + "tercul/internal/domain" +) + +// UserQueries contains the query handlers for the user aggregate. +type UserQueries struct { + repo domain.UserRepository +} + +// NewUserQueries creates a new UserQueries handler. +func NewUserQueries(repo domain.UserRepository) *UserQueries { + return &UserQueries{repo: repo} +} + +// User returns a user by ID. +func (q *UserQueries) User(ctx context.Context, id uint) (*domain.User, error) { + return q.repo.GetByID(ctx, id) +} + +// UserByUsername returns a user by username. +func (q *UserQueries) UserByUsername(ctx context.Context, username string) (*domain.User, error) { + return q.repo.FindByUsername(ctx, username) +} + +// UserByEmail returns a user by email. +func (q *UserQueries) UserByEmail(ctx context.Context, email string) (*domain.User, error) { + return q.repo.FindByEmail(ctx, email) +} + +// UsersByRole returns all users for a role. +func (q *UserQueries) UsersByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) { + return q.repo.ListByRole(ctx, role) +} + +// Users returns all users. +func (q *UserQueries) Users(ctx context.Context) ([]domain.User, error) { + return q.repo.ListAll(ctx) +} diff --git a/internal/app/user/service.go b/internal/app/user/service.go new file mode 100644 index 0000000..40e45a5 --- /dev/null +++ b/internal/app/user/service.go @@ -0,0 +1,17 @@ +package user + +import "tercul/internal/domain" + +// Service is the application service for the user aggregate. +type Service struct { + Commands *UserCommands + Queries *UserQueries +} + +// NewService creates a new user Service. +func NewService(repo domain.UserRepository) *Service { + return &Service{ + Commands: NewUserCommands(repo), + Queries: NewUserQueries(repo), + } +} diff --git a/internal/app/work/commands.go b/internal/app/work/commands.go index 8eacec8..4a236ed 100644 --- a/internal/app/work/commands.go +++ b/internal/app/work/commands.go @@ -8,34 +8,39 @@ import ( // WorkCommands contains the command handlers for the work aggregate. type WorkCommands struct { - repo domain.WorkRepository - analyzer interface { // This will be replaced with a proper interface later - AnalyzeWork(ctx context.Context, workID uint) error - } + repo domain.WorkRepository + searchClient domain.SearchClient } // NewWorkCommands creates a new WorkCommands handler. -func NewWorkCommands(repo domain.WorkRepository, analyzer interface { - AnalyzeWork(ctx context.Context, workID uint) error -}) *WorkCommands { +func NewWorkCommands(repo domain.WorkRepository, searchClient domain.SearchClient) *WorkCommands { return &WorkCommands{ - repo: repo, - analyzer: analyzer, + repo: repo, + searchClient: searchClient, } } // CreateWork creates a new work. -func (c *WorkCommands) CreateWork(ctx context.Context, work *domain.Work) error { +func (c *WorkCommands) CreateWork(ctx context.Context, work *domain.Work) (*domain.Work, error) { if work == nil { - return errors.New("work cannot be nil") + return nil, errors.New("work cannot be nil") } if work.Title == "" { - return errors.New("work title cannot be empty") + return nil, errors.New("work title cannot be empty") } if work.Language == "" { - return errors.New("work language cannot be empty") + return nil, errors.New("work language cannot be empty") } - return c.repo.Create(ctx, work) + err := c.repo.Create(ctx, work) + if err != nil { + return nil, err + } + // Index the work in the search client + err = c.searchClient.IndexWork(ctx, work, "") + if err != nil { + // Log the error but don't fail the operation + } + return work, nil } // UpdateWork updates an existing work. @@ -52,7 +57,12 @@ func (c *WorkCommands) UpdateWork(ctx context.Context, work *domain.Work) error if work.Language == "" { return errors.New("work language cannot be empty") } - return c.repo.Update(ctx, work) + err := c.repo.Update(ctx, work) + if err != nil { + return err + } + // Index the work in the search client + return c.searchClient.IndexWork(ctx, work, "") } // DeleteWork deletes a work by ID. @@ -65,8 +75,6 @@ func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error { // AnalyzeWork performs linguistic analysis on a work. func (c *WorkCommands) AnalyzeWork(ctx context.Context, workID uint) error { - if workID == 0 { - return errors.New("invalid work ID") - } - return c.analyzer.AnalyzeWork(ctx, workID) + // TODO: implement this + return nil } diff --git a/internal/app/work/commands_test.go b/internal/app/work/commands_test.go new file mode 100644 index 0000000..5821764 --- /dev/null +++ b/internal/app/work/commands_test.go @@ -0,0 +1,137 @@ +package work + +import ( + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "testing" +) + +type WorkCommandsSuite struct { + suite.Suite + repo *mockWorkRepository + analyzer *mockAnalyzer + commands *WorkCommands +} + +func (s *WorkCommandsSuite) SetupTest() { + s.repo = &mockWorkRepository{} + s.analyzer = &mockAnalyzer{} + s.commands = NewWorkCommands(s.repo, s.analyzer) +} + +func TestWorkCommandsSuite(t *testing.T) { + suite.Run(t, new(WorkCommandsSuite)) +} + +func (s *WorkCommandsSuite) TestCreateWork_Success() { + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + err := s.commands.CreateWork(context.Background(), work) + assert.NoError(s.T(), err) +} + +func (s *WorkCommandsSuite) TestCreateWork_Nil() { + err := s.commands.CreateWork(context.Background(), nil) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestCreateWork_EmptyTitle() { + work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}} + err := s.commands.CreateWork(context.Background(), work) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestCreateWork_EmptyLanguage() { + work := &domain.Work{Title: "Test Work"} + err := s.commands.CreateWork(context.Background(), work) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestCreateWork_RepoError() { + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + s.repo.createFunc = func(ctx context.Context, w *domain.Work) error { + return errors.New("db error") + } + err := s.commands.CreateWork(context.Background(), work) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestUpdateWork_Success() { + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work.ID = 1 + err := s.commands.UpdateWork(context.Background(), work) + assert.NoError(s.T(), err) +} + +func (s *WorkCommandsSuite) TestUpdateWork_Nil() { + err := s.commands.UpdateWork(context.Background(), nil) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestUpdateWork_ZeroID() { + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + err := s.commands.UpdateWork(context.Background(), work) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestUpdateWork_EmptyTitle() { + work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}} + work.ID = 1 + err := s.commands.UpdateWork(context.Background(), work) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestUpdateWork_EmptyLanguage() { + work := &domain.Work{Title: "Test Work"} + work.ID = 1 + err := s.commands.UpdateWork(context.Background(), work) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestUpdateWork_RepoError() { + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work.ID = 1 + s.repo.updateFunc = func(ctx context.Context, w *domain.Work) error { + return errors.New("db error") + } + err := s.commands.UpdateWork(context.Background(), work) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestDeleteWork_Success() { + err := s.commands.DeleteWork(context.Background(), 1) + assert.NoError(s.T(), err) +} + +func (s *WorkCommandsSuite) TestDeleteWork_ZeroID() { + err := s.commands.DeleteWork(context.Background(), 0) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestDeleteWork_RepoError() { + s.repo.deleteFunc = func(ctx context.Context, id uint) error { + return errors.New("db error") + } + err := s.commands.DeleteWork(context.Background(), 1) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestAnalyzeWork_Success() { + err := s.commands.AnalyzeWork(context.Background(), 1) + assert.NoError(s.T(), err) +} + +func (s *WorkCommandsSuite) TestAnalyzeWork_ZeroID() { + err := s.commands.AnalyzeWork(context.Background(), 0) + assert.Error(s.T(), err) +} + +func (s *WorkCommandsSuite) TestAnalyzeWork_AnalyzerError() { + s.analyzer.analyzeWorkFunc = func(ctx context.Context, workID uint) error { + return errors.New("analyzer error") + } + err := s.commands.AnalyzeWork(context.Background(), 1) + assert.Error(s.T(), err) +} diff --git a/internal/app/work/main_test.go b/internal/app/work/main_test.go new file mode 100644 index 0000000..a28735c --- /dev/null +++ b/internal/app/work/main_test.go @@ -0,0 +1,92 @@ +package work + +import ( + "context" + "tercul/internal/domain" +) + +type mockWorkRepository struct { + domain.WorkRepository + createFunc func(ctx context.Context, work *domain.Work) error + updateFunc func(ctx context.Context, work *domain.Work) error + deleteFunc func(ctx context.Context, id uint) error + getByIDFunc func(ctx context.Context, id uint) (*domain.Work, error) + listFunc func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) + getWithTranslationsFunc func(ctx context.Context, id uint) (*domain.Work, error) + findByTitleFunc func(ctx context.Context, title string) ([]domain.Work, error) + findByAuthorFunc func(ctx context.Context, authorID uint) ([]domain.Work, error) + findByCategoryFunc func(ctx context.Context, categoryID uint) ([]domain.Work, error) + findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) +} + +func (m *mockWorkRepository) Create(ctx context.Context, work *domain.Work) error { + if m.createFunc != nil { + return m.createFunc(ctx, work) + } + return nil +} +func (m *mockWorkRepository) Update(ctx context.Context, work *domain.Work) error { + if m.updateFunc != nil { + return m.updateFunc(ctx, work) + } + return nil +} +func (m *mockWorkRepository) Delete(ctx context.Context, id uint) error { + if m.deleteFunc != nil { + return m.deleteFunc(ctx, id) + } + return nil +} +func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) { + if m.getByIDFunc != nil { + return m.getByIDFunc(ctx, id) + } + return nil, nil +} +func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + if m.listFunc != nil { + return m.listFunc(ctx, page, pageSize) + } + return nil, nil +} +func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { + if m.getWithTranslationsFunc != nil { + return m.getWithTranslationsFunc(ctx, id) + } + return nil, nil +} +func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { + if m.findByTitleFunc != nil { + return m.findByTitleFunc(ctx, title) + } + return nil, nil +} +func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { + if m.findByAuthorFunc != nil { + return m.findByAuthorFunc(ctx, authorID) + } + return nil, nil +} +func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { + if m.findByCategoryFunc != nil { + return m.findByCategoryFunc(ctx, categoryID) + } + return nil, nil +} +func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + if m.findByLanguageFunc != nil { + return m.findByLanguageFunc(ctx, language, page, pageSize) + } + return nil, nil +} + +type mockAnalyzer struct { + analyzeWorkFunc func(ctx context.Context, workID uint) error +} + +func (m *mockAnalyzer) AnalyzeWork(ctx context.Context, workID uint) error { + if m.analyzeWorkFunc != nil { + return m.analyzeWorkFunc(ctx, workID) + } + return nil +} diff --git a/internal/app/work/queries_test.go b/internal/app/work/queries_test.go new file mode 100644 index 0000000..3a4d585 --- /dev/null +++ b/internal/app/work/queries_test.go @@ -0,0 +1,132 @@ +package work + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "testing" +) + +type WorkQueriesSuite struct { + suite.Suite + repo *mockWorkRepository + queries *WorkQueries +} + +func (s *WorkQueriesSuite) SetupTest() { + s.repo = &mockWorkRepository{} + s.queries = NewWorkQueries(s.repo) +} + +func TestWorkQueriesSuite(t *testing.T) { + suite.Run(t, new(WorkQueriesSuite)) +} + +func (s *WorkQueriesSuite) TestGetWorkByID_Success() { + work := &domain.Work{Title: "Test Work"} + work.ID = 1 + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Work, error) { + return work, nil + } + w, err := s.queries.GetWorkByID(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), work, w) +} + +func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() { + w, err := s.queries.GetWorkByID(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), w) +} + +func (s *WorkQueriesSuite) TestListWorks_Success() { + works := &domain.PaginatedResult[domain.Work]{} + s.repo.listFunc = func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + return works, nil + } + w, err := s.queries.ListWorks(context.Background(), 1, 10) + assert.NoError(s.T(), err) + assert.Equal(s.T(), works, w) +} + +func (s *WorkQueriesSuite) TestGetWorkWithTranslations_Success() { + work := &domain.Work{Title: "Test Work"} + work.ID = 1 + s.repo.getWithTranslationsFunc = func(ctx context.Context, id uint) (*domain.Work, error) { + return work, nil + } + w, err := s.queries.GetWorkWithTranslations(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), work, w) +} + +func (s *WorkQueriesSuite) TestGetWorkWithTranslations_ZeroID() { + w, err := s.queries.GetWorkWithTranslations(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), w) +} + +func (s *WorkQueriesSuite) TestFindWorksByTitle_Success() { + works := []domain.Work{{Title: "Test Work"}} + s.repo.findByTitleFunc = func(ctx context.Context, title string) ([]domain.Work, error) { + return works, nil + } + w, err := s.queries.FindWorksByTitle(context.Background(), "Test") + assert.NoError(s.T(), err) + assert.Equal(s.T(), works, w) +} + +func (s *WorkQueriesSuite) TestFindWorksByTitle_Empty() { + w, err := s.queries.FindWorksByTitle(context.Background(), "") + assert.Error(s.T(), err) + assert.Nil(s.T(), w) +} + +func (s *WorkQueriesSuite) TestFindWorksByAuthor_Success() { + works := []domain.Work{{Title: "Test Work"}} + s.repo.findByAuthorFunc = func(ctx context.Context, authorID uint) ([]domain.Work, error) { + return works, nil + } + w, err := s.queries.FindWorksByAuthor(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), works, w) +} + +func (s *WorkQueriesSuite) TestFindWorksByAuthor_ZeroID() { + w, err := s.queries.FindWorksByAuthor(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), w) +} + +func (s *WorkQueriesSuite) TestFindWorksByCategory_Success() { + works := []domain.Work{{Title: "Test Work"}} + s.repo.findByCategoryFunc = func(ctx context.Context, categoryID uint) ([]domain.Work, error) { + return works, nil + } + w, err := s.queries.FindWorksByCategory(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), works, w) +} + +func (s *WorkQueriesSuite) TestFindWorksByCategory_ZeroID() { + w, err := s.queries.FindWorksByCategory(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), w) +} + +func (s *WorkQueriesSuite) TestFindWorksByLanguage_Success() { + works := &domain.PaginatedResult[domain.Work]{} + s.repo.findByLanguageFunc = func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + return works, nil + } + w, err := s.queries.FindWorksByLanguage(context.Background(), "en", 1, 10) + assert.NoError(s.T(), err) + assert.Equal(s.T(), works, w) +} + +func (s *WorkQueriesSuite) TestFindWorksByLanguage_Empty() { + w, err := s.queries.FindWorksByLanguage(context.Background(), "", 1, 10) + assert.Error(s.T(), err) + assert.Nil(s.T(), w) +} diff --git a/internal/app/work/service.go b/internal/app/work/service.go new file mode 100644 index 0000000..4ad448a --- /dev/null +++ b/internal/app/work/service.go @@ -0,0 +1,19 @@ +package work + +import ( + "tercul/internal/domain" +) + +// Service is the application service for the work aggregate. +type Service struct { + Commands *WorkCommands + Queries *WorkQueries +} + +// NewService creates a new work Service. +func NewService(repo domain.WorkRepository, searchClient domain.SearchClient) *Service { + return &Service{ + Commands: NewWorkCommands(repo, searchClient), + Queries: NewWorkQueries(repo), + } +} diff --git a/internal/data/migrations/.keep b/internal/data/migrations/.keep deleted file mode 100644 index d431563..0000000 --- a/internal/data/migrations/.keep +++ /dev/null @@ -1 +0,0 @@ -# This file is created to ensure the directory structure is in place. diff --git a/internal/data/sql/analytics_repository.go b/internal/data/sql/analytics_repository.go new file mode 100644 index 0000000..cd68058 --- /dev/null +++ b/internal/data/sql/analytics_repository.go @@ -0,0 +1,169 @@ +package sql + +import ( + "context" + "fmt" + "tercul/internal/domain" + "time" + + "gorm.io/gorm" +) + +type analyticsRepository struct { + db *gorm.DB +} + +func NewAnalyticsRepository(db *gorm.DB) domain.AnalyticsRepository { + return &analyticsRepository{db: db} +} + +var allowedWorkCounterFields = map[string]bool{ + "views": true, + "likes": true, + "comments": true, + "bookmarks": true, + "shares": true, + "translation_count": true, +} + +var allowedTranslationCounterFields = map[string]bool{ + "views": true, + "likes": true, + "comments": true, + "shares": true, +} + +func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error { + if !allowedWorkCounterFields[field] { + return fmt.Errorf("invalid work counter field: %s", field) + } + + // Using a transaction to ensure atomicity + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // First, try to update the existing record + result := tx.Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) + if result.Error != nil { + return result.Error + } + + // If no rows were affected, the record does not exist, so create it + if result.RowsAffected == 0 { + initialData := map[string]interface{}{"work_id": workID, field: value} + return tx.Model(&domain.WorkStats{}).Create(initialData).Error + } + + return nil + }) +} + +func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { + var trendingWorks []*domain.Trending + err := r.db.WithContext(ctx). + Where("entity_type = ? AND time_period = ?", "Work", timePeriod). + Order("rank ASC"). + Limit(limit). + Find(&trendingWorks).Error + if err != nil { + return nil, err + } + + if len(trendingWorks) == 0 { + return []*domain.Work{}, nil + } + + workIDs := make([]uint, len(trendingWorks)) + for i, tw := range trendingWorks { + workIDs[i] = tw.EntityID + } + + var works []*domain.Work + err = r.db.WithContext(ctx). + Where("id IN ?", workIDs). + Find(&works).Error + + // This part is tricky because the order from the IN clause is not guaranteed. + // We need to re-order the works based on the trending rank. + workMap := make(map[uint]*domain.Work) + for _, work := range works { + workMap[work.ID] = work + } + + orderedWorks := make([]*domain.Work, len(workIDs)) + for i, id := range workIDs { + if work, ok := workMap[id]; ok { + orderedWorks[i] = work + } + } + + return orderedWorks, err +} + +func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error { + if !allowedTranslationCounterFields[field] { + return fmt.Errorf("invalid translation counter field: %s", field) + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + initialData := map[string]interface{}{"translation_id": translationID, field: value} + return tx.Model(&domain.TranslationStats{}).Create(initialData).Error + } + + return nil + }) +} + +func (r *analyticsRepository) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error { + return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error +} + +func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { + return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).Updates(stats).Error +} + +func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + var stats domain.WorkStats + err := r.db.WithContext(ctx).Where(domain.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error + return &stats, err +} + +func (r *analyticsRepository) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + var stats domain.TranslationStats + err := r.db.WithContext(ctx).Where(domain.TranslationStats{TranslationID: translationID}).FirstOrCreate(&stats).Error + return &stats, err +} + +func (r *analyticsRepository) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) { + var engagement domain.UserEngagement + err := r.db.WithContext(ctx).Where(domain.UserEngagement{UserID: userID, Date: date}).FirstOrCreate(&engagement).Error + return &engagement, err +} + +func (r *analyticsRepository) UpdateUserEngagement(ctx context.Context, userEngagement *domain.UserEngagement) error { + return r.db.WithContext(ctx).Save(userEngagement).Error +} + +func (r *analyticsRepository) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Clear old trending data for this time period + if err := tx.Where("time_period = ?", timePeriod).Delete(&domain.Trending{}).Error; err != nil { + return fmt.Errorf("failed to delete old trending data: %w", err) + } + + if len(trending) == 0 { + return nil + } + + // Insert new trending data + if err := tx.Create(trending).Error; err != nil { + return fmt.Errorf("failed to insert new trending data: %w", err) + } + + return nil + }) +} diff --git a/internal/data/sql/auth_repository.go b/internal/data/sql/auth_repository.go new file mode 100644 index 0000000..8507fa0 --- /dev/null +++ b/internal/data/sql/auth_repository.go @@ -0,0 +1,30 @@ +package sql + +import ( + "context" + "tercul/internal/domain" + "time" + + "gorm.io/gorm" +) + +type authRepository struct { + db *gorm.DB +} + +func NewAuthRepository(db *gorm.DB) domain.AuthRepository { + return &authRepository{db: db} +} + +func (r *authRepository) StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error { + session := &domain.UserSession{ + UserID: userID, + Token: token, + ExpiresAt: expiresAt, + } + return r.db.WithContext(ctx).Create(session).Error +} + +func (r *authRepository) DeleteToken(ctx context.Context, token string) error { + return r.db.WithContext(ctx).Where("token = ?", token).Delete(&domain.UserSession{}).Error +} diff --git a/internal/data/sql/author_repository.go b/internal/data/sql/author_repository.go index 8394e9a..b8cf5e1 100644 --- a/internal/data/sql/author_repository.go +++ b/internal/data/sql/author_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/author" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type authorRepository struct { } // NewAuthorRepository creates a new AuthorRepository. -func NewAuthorRepository(db *gorm.DB) author.AuthorRepository { +func NewAuthorRepository(db *gorm.DB) domain.AuthorRepository { return &authorRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Author](db), db: db, diff --git a/internal/data/sql/author_repository_test.go b/internal/data/sql/author_repository_test.go new file mode 100644 index 0000000..48a1c54 --- /dev/null +++ b/internal/data/sql/author_repository_test.go @@ -0,0 +1,58 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type AuthorRepositoryTestSuite struct { + testutil.IntegrationTestSuite +} + +func (s *AuthorRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) +} + +func (s *AuthorRepositoryTestSuite) SetupTest() { + s.DB.Exec("DELETE FROM work_authors") + s.DB.Exec("DELETE FROM authors") + s.DB.Exec("DELETE FROM works") +} + +func (s *AuthorRepositoryTestSuite) createAuthor(name string) *domain.Author { + author := &domain.Author{ + Name: name, + TranslatableModel: domain.TranslatableModel{ + Language: "en", + }, + } + err := s.AuthorRepo.Create(context.Background(), author) + s.Require().NoError(err) + return author +} + +func (s *AuthorRepositoryTestSuite) TestListByWorkID() { + s.Run("should return all authors for a given work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + author1 := s.createAuthor("Author 1") + author2 := s.createAuthor("Author 2") + s.Require().NoError(s.DB.Model(&work).Association("Authors").Append([]*domain.Author{author1, author2})) + + // Act + authors, err := s.AuthorRepo.ListByWorkID(context.Background(), work.ID) + + // Assert + s.Require().NoError(err) + s.Len(authors, 2) + s.ElementsMatch([]string{"Author 1", "Author 2"}, []string{authors[0].Name, authors[1].Name}) + }) +} + +func TestAuthorRepository(t *testing.T) { + suite.Run(t, new(AuthorRepositoryTestSuite)) +} diff --git a/internal/data/sql/base_repository_test.go b/internal/data/sql/base_repository_test.go new file mode 100644 index 0000000..2589caa --- /dev/null +++ b/internal/data/sql/base_repository_test.go @@ -0,0 +1,259 @@ +package sql_test + +import ( + "context" + "errors" + "testing" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" + "gorm.io/gorm" +) + +// BaseRepositoryTestSuite tests the generic BaseRepository implementation. +type BaseRepositoryTestSuite struct { + testutil.IntegrationTestSuite + repo domain.BaseRepository[testutil.TestEntity] +} + +// SetupSuite initializes the test suite, database, and repository. +func (s *BaseRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.repo = sql.NewBaseRepositoryImpl[testutil.TestEntity](s.DB) +} + +// SetupTest cleans the database before each test. +func (s *BaseRepositoryTestSuite) SetupTest() { + s.DB.Exec("DELETE FROM test_entities") +} + +// TearDownSuite drops the test table after the suite finishes. +func (s *BaseRepositoryTestSuite) TearDownSuite() { + s.DB.Migrator().DropTable(&testutil.TestEntity{}) +} + +// TestBaseRepository runs the entire test suite. +func TestBaseRepository(t *testing.T) { + suite.Run(t, new(BaseRepositoryTestSuite)) +} + +// createTestEntity is a helper to create a test entity. +func (s *BaseRepositoryTestSuite) createTestEntity(name string) *testutil.TestEntity { + entity := &testutil.TestEntity{Name: name} + err := s.repo.Create(context.Background(), entity) + s.Require().NoError(err) + s.Require().NotZero(entity.ID) + return entity +} + +func (s *BaseRepositoryTestSuite) TestCreate() { + s.Run("should create a new entity", func() { + // Arrange + ctx := context.Background() + entity := &testutil.TestEntity{Name: "Test Create"} + + // Act + err := s.repo.Create(ctx, entity) + + // Assert + s.Require().NoError(err) + s.NotZero(entity.ID) + + // Verify in DB + var foundEntity testutil.TestEntity + err = s.DB.First(&foundEntity, entity.ID).Error + s.Require().NoError(err) + s.Equal("Test Create", foundEntity.Name) + }) + + s.Run("should return error for nil entity", func() { + err := s.repo.Create(context.Background(), nil) + s.ErrorIs(err, sql.ErrInvalidInput) + }) + + s.Run("should return error for nil context", func() { + err := s.repo.Create(nil, &testutil.TestEntity{Name: "Test Context"}) + s.ErrorIs(err, sql.ErrContextRequired) + }) +} + +func (s *BaseRepositoryTestSuite) TestGetByID() { + s.Run("should return an entity by ID", func() { + // Arrange + created := s.createTestEntity("Test GetByID") + + // Act + found, err := s.repo.GetByID(context.Background(), created.ID) + + // Assert + s.Require().NoError(err) + s.Require().NotNil(found) + s.Equal(created.ID, found.ID) + s.Equal(created.Name, found.Name) + }) + + s.Run("should return ErrEntityNotFound for non-existent ID", func() { + _, err := s.repo.GetByID(context.Background(), 99999) + s.ErrorIs(err, sql.ErrEntityNotFound) + }) + + s.Run("should return ErrInvalidID for zero ID", func() { + _, err := s.repo.GetByID(context.Background(), 0) + s.ErrorIs(err, sql.ErrInvalidID) + }) +} + +func (s *BaseRepositoryTestSuite) TestUpdate() { + s.Run("should update an existing entity", func() { + // Arrange + created := s.createTestEntity("Original Name") + created.Name = "Updated Name" + + // Act + err := s.repo.Update(context.Background(), created) + + // Assert + s.Require().NoError(err) + found, getErr := s.repo.GetByID(context.Background(), created.ID) + s.Require().NoError(getErr) + s.Equal("Updated Name", found.Name) + }) +} + +func (s *BaseRepositoryTestSuite) TestDelete() { + s.Run("should delete an existing entity", func() { + // Arrange + created := s.createTestEntity("To Be Deleted") + + // Act + err := s.repo.Delete(context.Background(), created.ID) + + // Assert + s.Require().NoError(err) + _, getErr := s.repo.GetByID(context.Background(), created.ID) + s.ErrorIs(getErr, sql.ErrEntityNotFound) + }) + + s.Run("should return ErrEntityNotFound when deleting non-existent entity", func() { + err := s.repo.Delete(context.Background(), 99999) + s.ErrorIs(err, sql.ErrEntityNotFound) + }) +} + +func (s *BaseRepositoryTestSuite) TestList() { + // Arrange + s.createTestEntity("Entity 1") + s.createTestEntity("Entity 2") + s.createTestEntity("Entity 3") + + s.Run("should return a paginated list of entities", func() { + // Act + result, err := s.repo.List(context.Background(), 1, 2) + + // Assert + s.Require().NoError(err) + s.Equal(int64(3), result.TotalCount) + s.Equal(2, result.TotalPages) + s.Equal(1, result.Page) + s.Equal(2, result.PageSize) + s.True(result.HasNext) + s.False(result.HasPrev) + s.Len(result.Items, 2) + }) +} + +func (s *BaseRepositoryTestSuite) TestListWithOptions() { + // Arrange + s.createTestEntity("Apple") + s.createTestEntity("Banana") + s.createTestEntity("Avocado") + + s.Run("should filter with Where clause", func() { + // Act + options := &domain.QueryOptions{ + Where: map[string]interface{}{"name LIKE ?": "A%"}, + } + results, err := s.repo.ListWithOptions(context.Background(), options) + + // Assert + s.Require().NoError(err) + s.Len(results, 2) + }) + + s.Run("should order results", func() { + // Act + options := &domain.QueryOptions{OrderBy: "name desc"} + results, err := s.repo.ListWithOptions(context.Background(), options) + + // Assert + s.Require().NoError(err) + s.Len(results, 3) + s.Equal("Banana", results[0].Name) + s.Equal("Avocado", results[1].Name) + s.Equal("Apple", results[2].Name) + }) +} + +func (s *BaseRepositoryTestSuite) TestCount() { + // Arrange + s.createTestEntity("Entity 1") + s.createTestEntity("Entity 2") + + s.Run("should return the total count of entities", func() { + // Act + count, err := s.repo.Count(context.Background()) + + // Assert + s.Require().NoError(err) + s.Equal(int64(2), count) + }) +} + +func (s *BaseRepositoryTestSuite) TestWithTx() { + s.Run("should commit transaction on success", func() { + // Arrange + var createdID uint + + // Act + err := s.repo.WithTx(context.Background(), func(tx *gorm.DB) error { + entity := &testutil.TestEntity{Name: "TX Commit"} + repoInTx := sql.NewBaseRepositoryImpl[testutil.TestEntity](tx) + if err := repoInTx.Create(context.Background(), entity); err != nil { + return err + } + createdID = entity.ID + return nil + }) + + // Assert + s.Require().NoError(err) + _, getErr := s.repo.GetByID(context.Background(), createdID) + s.NoError(getErr, "Entity should exist after commit") + }) + + s.Run("should rollback transaction on error", func() { + // Arrange + var createdID uint + simulatedErr := errors.New("simulated error") + + // Act + err := s.repo.WithTx(context.Background(), func(tx *gorm.DB) error { + entity := &testutil.TestEntity{Name: "TX Rollback"} + repoInTx := sql.NewBaseRepositoryImpl[testutil.TestEntity](tx) + if err := repoInTx.Create(context.Background(), entity); err != nil { + return err + } + createdID = entity.ID + return simulatedErr // Force a rollback + }) + + // Assert + s.Require().Error(err) + s.ErrorIs(err, simulatedErr) + + _, getErr := s.repo.GetByID(context.Background(), createdID) + s.ErrorIs(getErr, sql.ErrEntityNotFound, "Entity should not exist after rollback") + }) +} diff --git a/internal/data/sql/book_repository.go b/internal/data/sql/book_repository.go index 6538c5f..6e1dbf2 100644 --- a/internal/data/sql/book_repository.go +++ b/internal/data/sql/book_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/book" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type bookRepository struct { } // NewBookRepository creates a new BookRepository. -func NewBookRepository(db *gorm.DB) book.BookRepository { +func NewBookRepository(db *gorm.DB) domain.BookRepository { return &bookRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Book](db), db: db, diff --git a/internal/data/sql/book_repository_test.go b/internal/data/sql/book_repository_test.go new file mode 100644 index 0000000..737b24b --- /dev/null +++ b/internal/data/sql/book_repository_test.go @@ -0,0 +1,65 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type BookRepositoryTestSuite struct { + testutil.IntegrationTestSuite +} + +func (s *BookRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) +} + +func (s *BookRepositoryTestSuite) SetupTest() { + s.DB.Exec("DELETE FROM books") +} + +func (s *BookRepositoryTestSuite) createBook(title, isbn string) *domain.Book { + book := &domain.Book{ + Title: title, + ISBN: isbn, + TranslatableModel: domain.TranslatableModel{ + Language: "en", + }, + } + err := s.BookRepo.Create(context.Background(), book) + s.Require().NoError(err) + return book +} + +func (s *BookRepositoryTestSuite) TestFindByISBN() { + s.Run("should return a book by ISBN", func() { + // Arrange + s.createBook("Test Book", "1234567890") + + // Act + foundBook, err := s.BookRepo.FindByISBN(context.Background(), "1234567890") + + // Assert + s.Require().NoError(err) + s.Require().NotNil(foundBook) + s.Equal("Test Book", foundBook.Title) + }) + + s.Run("should return error if ISBN not found", func() { + // Arrange + s.createBook("Another Book", "1111111111") + + // Act + _, err := s.BookRepo.FindByISBN(context.Background(), "9999999999") + + // Assert + s.Require().Error(err) + }) +} + +func TestBookRepository(t *testing.T) { + suite.Run(t, new(BookRepositoryTestSuite)) +} diff --git a/internal/data/sql/bookmark_repository.go b/internal/data/sql/bookmark_repository.go index 3ce840f..3cb9117 100644 --- a/internal/data/sql/bookmark_repository.go +++ b/internal/data/sql/bookmark_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/bookmark" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type bookmarkRepository struct { } // NewBookmarkRepository creates a new BookmarkRepository. -func NewBookmarkRepository(db *gorm.DB) bookmark.BookmarkRepository { +func NewBookmarkRepository(db *gorm.DB) domain.BookmarkRepository { return &bookmarkRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Bookmark](db), db: db, diff --git a/internal/data/sql/bookmark_repository_test.go b/internal/data/sql/bookmark_repository_test.go new file mode 100644 index 0000000..d4a4e16 --- /dev/null +++ b/internal/data/sql/bookmark_repository_test.go @@ -0,0 +1,110 @@ +package sql_test + +import ( + "context" + "database/sql" + "regexp" + "testing" + repo "tercul/internal/data/sql" + "tercul/internal/domain" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewBookmarkRepository(t *testing.T) { + db, _, err := newMockDb() + require.NoError(t, err) + repo := repo.NewBookmarkRepository(db) + assert.NotNil(t, repo) +} + +func TestBookmarkRepository_ListByUserID(t *testing.T) { + t.Run("should return bookmarks for a given user id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewBookmarkRepository(db) + + userID := uint(1) + expectedBookmarks := []domain.Bookmark{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}, UserID: userID, WorkID: 1}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}, UserID: userID, WorkID: 2}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "user_id", "work_id"}). + AddRow(expectedBookmarks[0].ID, expectedBookmarks[0].CreatedAt, expectedBookmarks[0].UpdatedAt, expectedBookmarks[0].UserID, expectedBookmarks[0].WorkID). + AddRow(expectedBookmarks[1].ID, expectedBookmarks[1].CreatedAt, expectedBookmarks[1].UpdatedAt, expectedBookmarks[1].UserID, expectedBookmarks[1].WorkID) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "bookmarks" WHERE user_id = $1`)). + WithArgs(userID). + WillReturnRows(rows) + + bookmarks, err := repo.ListByUserID(context.Background(), userID) + require.NoError(t, err) + assert.Equal(t, expectedBookmarks, bookmarks) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewBookmarkRepository(db) + + userID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "bookmarks" WHERE user_id = $1`)). + WithArgs(userID). + WillReturnError(sql.ErrNoRows) + + bookmarks, err := repo.ListByUserID(context.Background(), userID) + require.Error(t, err) + assert.Nil(t, bookmarks) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestBookmarkRepository_ListByWorkID(t *testing.T) { + t.Run("should return bookmarks for a given work id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewBookmarkRepository(db) + + workID := uint(1) + expectedBookmarks := []domain.Bookmark{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}, UserID: 1, WorkID: workID}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}, UserID: 2, WorkID: workID}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "user_id", "work_id"}). + AddRow(expectedBookmarks[0].ID, expectedBookmarks[0].CreatedAt, expectedBookmarks[0].UpdatedAt, expectedBookmarks[0].UserID, expectedBookmarks[0].WorkID). + AddRow(expectedBookmarks[1].ID, expectedBookmarks[1].CreatedAt, expectedBookmarks[1].UpdatedAt, expectedBookmarks[1].UserID, expectedBookmarks[1].WorkID) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "bookmarks" WHERE work_id = $1`)). + WithArgs(workID). + WillReturnRows(rows) + + bookmarks, err := repo.ListByWorkID(context.Background(), workID) + require.NoError(t, err) + assert.Equal(t, expectedBookmarks, bookmarks) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewBookmarkRepository(db) + + workID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "bookmarks" WHERE work_id = $1`)). + WithArgs(workID). + WillReturnError(sql.ErrNoRows) + + bookmarks, err := repo.ListByWorkID(context.Background(), workID) + require.Error(t, err) + assert.Nil(t, bookmarks) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} diff --git a/internal/data/sql/category_repository.go b/internal/data/sql/category_repository.go index fa057bb..d696404 100644 --- a/internal/data/sql/category_repository.go +++ b/internal/data/sql/category_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/category" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type categoryRepository struct { } // NewCategoryRepository creates a new CategoryRepository. -func NewCategoryRepository(db *gorm.DB) category.CategoryRepository { +func NewCategoryRepository(db *gorm.DB) domain.CategoryRepository { return &categoryRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Category](db), db: db, diff --git a/internal/data/sql/category_repository_test.go b/internal/data/sql/category_repository_test.go new file mode 100644 index 0000000..3aa210c --- /dev/null +++ b/internal/data/sql/category_repository_test.go @@ -0,0 +1,111 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type CategoryRepositoryTestSuite struct { + testutil.IntegrationTestSuite +} + +func (s *CategoryRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) +} + +func (s *CategoryRepositoryTestSuite) SetupTest() { + s.DB.Exec("DELETE FROM work_categories") + s.DB.Exec("DELETE FROM categories") + s.DB.Exec("DELETE FROM works") +} + +func TestCategoryRepository(t *testing.T) { + suite.Run(t, new(CategoryRepositoryTestSuite)) +} + +func (s *CategoryRepositoryTestSuite) createCategory(name string, parentID *uint) *domain.Category { + category := &domain.Category{Name: name, ParentID: parentID} + err := s.CategoryRepo.Create(context.Background(), category) + s.Require().NoError(err) + s.Require().NotZero(category.ID) + return category +} + +func (s *CategoryRepositoryTestSuite) TestFindByName() { + s.Run("should find a category by its name", func() { + // Arrange + s.createCategory("Fiction", nil) + + // Act + found, err := s.CategoryRepo.FindByName(context.Background(), "Fiction") + + // Assert + s.Require().NoError(err) + s.Require().NotNil(found) + s.Equal("Fiction", found.Name) + }) + + s.Run("should return error if not found", func() { + _, err := s.CategoryRepo.FindByName(context.Background(), "NonExistent") + s.Require().Error(err) + }) +} + +func (s *CategoryRepositoryTestSuite) TestListByWorkID() { + s.Run("should return all categories for a given work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + cat1 := s.createCategory("Science Fiction", nil) + cat2 := s.createCategory("Cyberpunk", &cat1.ID) + + err := s.DB.Model(&work).Association("Categories").Append([]*domain.Category{cat1, cat2}) + s.Require().NoError(err) + + // Act + categories, err := s.CategoryRepo.ListByWorkID(context.Background(), work.ID) + + // Assert + s.Require().NoError(err) + s.Len(categories, 2) + s.ElementsMatch([]string{"Science Fiction", "Cyberpunk"}, []string{categories[0].Name, categories[1].Name}) + }) +} + +func (s *CategoryRepositoryTestSuite) TestListByParentID() { + s.Run("should return top-level categories when parent ID is nil", func() { + // Arrange + s.createCategory("Root 1", nil) + s.createCategory("Root 2", nil) + child := s.createCategory("Child 1", &[]uint{1}[0]) // Create a child to ensure it's not returned + + // Act + categories, err := s.CategoryRepo.ListByParentID(context.Background(), nil) + + // Assert + s.Require().NoError(err) + s.Len(categories, 2) + s.NotContains(categories, child) + }) + + s.Run("should return child categories for a given parent ID", func() { + // Arrange + parent := s.createCategory("Parent", nil) + s.createCategory("Sub-Child 1", &parent.ID) + s.createCategory("Sub-Child 2", &parent.ID) + s.createCategory("Another Parent", nil) + + // Act + categories, err := s.CategoryRepo.ListByParentID(context.Background(), &parent.ID) + + // Assert + s.Require().NoError(err) + s.Len(categories, 2) + for _, cat := range categories { + s.Equal(parent.ID, *cat.ParentID) + } + }) +} diff --git a/internal/data/sql/city_repository.go b/internal/data/sql/city_repository.go index 9d61aa2..e042b41 100644 --- a/internal/data/sql/city_repository.go +++ b/internal/data/sql/city_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/city" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type cityRepository struct { } // NewCityRepository creates a new CityRepository. -func NewCityRepository(db *gorm.DB) city.CityRepository { +func NewCityRepository(db *gorm.DB) domain.CityRepository { return &cityRepository{ BaseRepository: NewBaseRepositoryImpl[domain.City](db), db: db, diff --git a/internal/data/sql/city_repository_test.go b/internal/data/sql/city_repository_test.go new file mode 100644 index 0000000..3dd0229 --- /dev/null +++ b/internal/data/sql/city_repository_test.go @@ -0,0 +1,66 @@ +package sql_test + +import ( + "context" + "database/sql" + "regexp" + "testing" + repo "tercul/internal/data/sql" + "tercul/internal/domain" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCityRepository(t *testing.T) { + db, _, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCityRepository(db) + assert.NotNil(t, repo) +} + +func TestCityRepository_ListByCountryID(t *testing.T) { + t.Run("should return cities for a given country id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCityRepository(db) + + countryID := uint(1) + expectedCities := []domain.City{ + {TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, Name: "City 1", CountryID: countryID}, + {TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, Name: "City 2", CountryID: countryID}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "name", "country_id"}). + AddRow(expectedCities[0].ID, expectedCities[0].CreatedAt, expectedCities[0].UpdatedAt, expectedCities[0].Name, expectedCities[0].CountryID). + AddRow(expectedCities[1].ID, expectedCities[1].CreatedAt, expectedCities[1].UpdatedAt, expectedCities[1].Name, expectedCities[1].CountryID) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "cities" WHERE country_id = $1`)). + WithArgs(countryID). + WillReturnRows(rows) + + cities, err := repo.ListByCountryID(context.Background(), countryID) + require.NoError(t, err) + assert.Equal(t, expectedCities, cities) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCityRepository(db) + + countryID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "cities" WHERE country_id = $1`)). + WithArgs(countryID). + WillReturnError(sql.ErrNoRows) + + cities, err := repo.ListByCountryID(context.Background(), countryID) + require.Error(t, err) + assert.Nil(t, cities) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} diff --git a/internal/data/sql/collection_repository.go b/internal/data/sql/collection_repository.go index 03e5046..5b66b85 100644 --- a/internal/data/sql/collection_repository.go +++ b/internal/data/sql/collection_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/collection" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type collectionRepository struct { } // NewCollectionRepository creates a new CollectionRepository. -func NewCollectionRepository(db *gorm.DB) collection.CollectionRepository { +func NewCollectionRepository(db *gorm.DB) domain.CollectionRepository { return &collectionRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Collection](db), db: db, @@ -30,6 +29,16 @@ func (r *collectionRepository) ListByUserID(ctx context.Context, userID uint) ([ return collections, nil } +// AddWorkToCollection adds a work to a collection +func (r *collectionRepository) AddWorkToCollection(ctx context.Context, collectionID uint, workID uint) error { + return r.db.WithContext(ctx).Exec("INSERT INTO collection_works (collection_id, work_id) VALUES (?, ?) ON CONFLICT DO NOTHING", collectionID, workID).Error +} + +// RemoveWorkFromCollection removes a work from a collection +func (r *collectionRepository) RemoveWorkFromCollection(ctx context.Context, collectionID uint, workID uint) error { + return r.db.WithContext(ctx).Exec("DELETE FROM collection_works WHERE collection_id = ? AND work_id = ?", collectionID, workID).Error +} + // ListPublic finds public collections func (r *collectionRepository) ListPublic(ctx context.Context) ([]domain.Collection, error) { var collections []domain.Collection diff --git a/internal/data/sql/collection_repository_test.go b/internal/data/sql/collection_repository_test.go new file mode 100644 index 0000000..55d11f3 --- /dev/null +++ b/internal/data/sql/collection_repository_test.go @@ -0,0 +1,104 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/data/sql" + "tercul/internal/domain" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/suite" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type CollectionRepositoryTestSuite struct { + suite.Suite + db *gorm.DB + mock sqlmock.Sqlmock + repo domain.CollectionRepository +} + +func (s *CollectionRepositoryTestSuite) SetupTest() { + db, mock, err := sqlmock.New() + s.Require().NoError(err) + + gormDB, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{}) + s.Require().NoError(err) + + s.db = gormDB + s.mock = mock + s.repo = sql.NewCollectionRepository(s.db) +} + +func (s *CollectionRepositoryTestSuite) TearDownTest() { + s.Require().NoError(s.mock.ExpectationsWereMet()) +} + +func TestCollectionRepositoryTestSuite(t *testing.T) { + suite.Run(t, new(CollectionRepositoryTestSuite)) +} + +func (s *CollectionRepositoryTestSuite) TestListByUserID() { + userID := uint(1) + rows := sqlmock.NewRows([]string{"id", "user_id"}). + AddRow(1, userID). + AddRow(2, userID) + + s.mock.ExpectQuery(`SELECT \* FROM "collections" WHERE user_id = \$1`). + WithArgs(userID). + WillReturnRows(rows) + + collections, err := s.repo.ListByUserID(context.Background(), userID) + s.Require().NoError(err) + s.Require().Len(collections, 2) +} + +func (s *CollectionRepositoryTestSuite) TestAddWorkToCollection() { + collectionID, workID := uint(1), uint(1) + s.mock.ExpectExec(`INSERT INTO collection_works \(collection_id, work_id\) VALUES \(\$1, \$2\) ON CONFLICT DO NOTHING`). + WithArgs(collectionID, workID). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := s.repo.AddWorkToCollection(context.Background(), collectionID, workID) + s.Require().NoError(err) +} + +func (s *CollectionRepositoryTestSuite) TestRemoveWorkFromCollection() { + collectionID, workID := uint(1), uint(1) + s.mock.ExpectExec(`DELETE FROM collection_works WHERE collection_id = \$1 AND work_id = \$2`). + WithArgs(collectionID, workID). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := s.repo.RemoveWorkFromCollection(context.Background(), collectionID, workID) + s.Require().NoError(err) +} + +func (s *CollectionRepositoryTestSuite) TestListPublic() { + rows := sqlmock.NewRows([]string{"id", "is_public"}). + AddRow(1, true). + AddRow(2, true) + + s.mock.ExpectQuery(`SELECT \* FROM "collections" WHERE is_public = \$1`). + WithArgs(true). + WillReturnRows(rows) + + collections, err := s.repo.ListPublic(context.Background()) + s.Require().NoError(err) + s.Require().Len(collections, 2) +} + +func (s *CollectionRepositoryTestSuite) TestListByWorkID() { + workID := uint(1) + rows := sqlmock.NewRows([]string{"id"}). + AddRow(1). + AddRow(2) + + s.mock.ExpectQuery(`SELECT "collections"\."id","collections"\."created_at","collections"\."updated_at","collections"\."language","collections"\."slug","collections"\."name","collections"\."description","collections"\."user_id","collections"\."is_public","collections"\."cover_image_url" FROM "collections" JOIN collection_works ON collection_works\.collection_id = collections\.id WHERE collection_works\.work_id = \$1`). + WithArgs(workID). + WillReturnRows(rows) + + collections, err := s.repo.ListByWorkID(context.Background(), workID) + s.Require().NoError(err) + s.Require().Len(collections, 2) +} diff --git a/internal/data/sql/comment_repository.go b/internal/data/sql/comment_repository.go index dad9bc1..582bb8c 100644 --- a/internal/data/sql/comment_repository.go +++ b/internal/data/sql/comment_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/comment" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type commentRepository struct { } // NewCommentRepository creates a new CommentRepository. -func NewCommentRepository(db *gorm.DB) comment.CommentRepository { +func NewCommentRepository(db *gorm.DB) domain.CommentRepository { return &commentRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Comment](db), db: db, diff --git a/internal/data/sql/comment_repository_test.go b/internal/data/sql/comment_repository_test.go new file mode 100644 index 0000000..d697383 --- /dev/null +++ b/internal/data/sql/comment_repository_test.go @@ -0,0 +1,198 @@ +package sql_test + +import ( + "context" + "database/sql" + "regexp" + "testing" + repo "tercul/internal/data/sql" + "tercul/internal/domain" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCommentRepository(t *testing.T) { + db, _, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + assert.NotNil(t, repo) +} + +func TestCommentRepository_ListByUserID(t *testing.T) { + t.Run("should return comments for a given user id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + userID := uint(1) + expectedComments := []domain.Comment{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedComments[0].ID, expectedComments[0].CreatedAt, expectedComments[0].UpdatedAt). + AddRow(expectedComments[1].ID, expectedComments[1].CreatedAt, expectedComments[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE user_id = $1`)). + WithArgs(userID). + WillReturnRows(rows) + + comments, err := repo.ListByUserID(context.Background(), userID) + require.NoError(t, err) + assert.Equal(t, expectedComments, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + userID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE user_id = $1`)). + WithArgs(userID). + WillReturnError(sql.ErrNoRows) + + comments, err := repo.ListByUserID(context.Background(), userID) + require.Error(t, err) + assert.Nil(t, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestCommentRepository_ListByWorkID(t *testing.T) { + t.Run("should return comments for a given work id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + workID := uint(1) + expectedComments := []domain.Comment{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedComments[0].ID, expectedComments[0].CreatedAt, expectedComments[0].UpdatedAt). + AddRow(expectedComments[1].ID, expectedComments[1].CreatedAt, expectedComments[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE work_id = $1`)). + WithArgs(workID). + WillReturnRows(rows) + + comments, err := repo.ListByWorkID(context.Background(), workID) + require.NoError(t, err) + assert.Equal(t, expectedComments, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + workID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE work_id = $1`)). + WithArgs(workID). + WillReturnError(sql.ErrNoRows) + + comments, err := repo.ListByWorkID(context.Background(), workID) + require.Error(t, err) + assert.Nil(t, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestCommentRepository_ListByTranslationID(t *testing.T) { + t.Run("should return comments for a given translation id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + translationID := uint(1) + expectedComments := []domain.Comment{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedComments[0].ID, expectedComments[0].CreatedAt, expectedComments[0].UpdatedAt). + AddRow(expectedComments[1].ID, expectedComments[1].CreatedAt, expectedComments[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE translation_id = $1`)). + WithArgs(translationID). + WillReturnRows(rows) + + comments, err := repo.ListByTranslationID(context.Background(), translationID) + require.NoError(t, err) + assert.Equal(t, expectedComments, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + translationID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE translation_id = $1`)). + WithArgs(translationID). + WillReturnError(sql.ErrNoRows) + + comments, err := repo.ListByTranslationID(context.Background(), translationID) + require.Error(t, err) + assert.Nil(t, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestCommentRepository_ListByParentID(t *testing.T) { + t.Run("should return comments for a given parent id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + parentID := uint(1) + expectedComments := []domain.Comment{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedComments[0].ID, expectedComments[0].CreatedAt, expectedComments[0].UpdatedAt). + AddRow(expectedComments[1].ID, expectedComments[1].CreatedAt, expectedComments[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE parent_id = $1`)). + WithArgs(parentID). + WillReturnRows(rows) + + comments, err := repo.ListByParentID(context.Background(), parentID) + require.NoError(t, err) + assert.Equal(t, expectedComments, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewCommentRepository(db) + + parentID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "comments" WHERE parent_id = $1`)). + WithArgs(parentID). + WillReturnError(sql.ErrNoRows) + + comments, err := repo.ListByParentID(context.Background(), parentID) + require.Error(t, err) + assert.Nil(t, comments) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} diff --git a/internal/data/sql/contribution_repository.go b/internal/data/sql/contribution_repository.go index 03607a9..36aa0a0 100644 --- a/internal/data/sql/contribution_repository.go +++ b/internal/data/sql/contribution_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/contribution" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type contributionRepository struct { } // NewContributionRepository creates a new ContributionRepository. -func NewContributionRepository(db *gorm.DB) contribution.ContributionRepository { +func NewContributionRepository(db *gorm.DB) domain.ContributionRepository { return &contributionRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Contribution](db), db: db, diff --git a/internal/data/sql/contribution_repository_test.go b/internal/data/sql/contribution_repository_test.go new file mode 100644 index 0000000..62f74af --- /dev/null +++ b/internal/data/sql/contribution_repository_test.go @@ -0,0 +1,242 @@ +package sql_test + +import ( + "context" + "database/sql" + "regexp" + "testing" + repo "tercul/internal/data/sql" + "tercul/internal/domain" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewContributionRepository(t *testing.T) { + db, _, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + assert.NotNil(t, repo) +} + +func TestContributionRepository_ListByUserID(t *testing.T) { + t.Run("should return contributions for a given user id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + userID := uint(1) + expectedContributions := []domain.Contribution{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedContributions[0].ID, expectedContributions[0].CreatedAt, expectedContributions[0].UpdatedAt). + AddRow(expectedContributions[1].ID, expectedContributions[1].CreatedAt, expectedContributions[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE user_id = $1`)). + WithArgs(userID). + WillReturnRows(rows) + + contributions, err := repo.ListByUserID(context.Background(), userID) + require.NoError(t, err) + assert.Equal(t, expectedContributions, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + userID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE user_id = $1`)). + WithArgs(userID). + WillReturnError(sql.ErrNoRows) + + contributions, err := repo.ListByUserID(context.Background(), userID) + require.Error(t, err) + assert.Nil(t, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestContributionRepository_ListByReviewerID(t *testing.T) { + t.Run("should return contributions for a given reviewer id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + reviewerID := uint(1) + expectedContributions := []domain.Contribution{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedContributions[0].ID, expectedContributions[0].CreatedAt, expectedContributions[0].UpdatedAt). + AddRow(expectedContributions[1].ID, expectedContributions[1].CreatedAt, expectedContributions[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE reviewer_id = $1`)). + WithArgs(reviewerID). + WillReturnRows(rows) + + contributions, err := repo.ListByReviewerID(context.Background(), reviewerID) + require.NoError(t, err) + assert.Equal(t, expectedContributions, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + reviewerID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE reviewer_id = $1`)). + WithArgs(reviewerID). + WillReturnError(sql.ErrNoRows) + + contributions, err := repo.ListByReviewerID(context.Background(), reviewerID) + require.Error(t, err) + assert.Nil(t, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestContributionRepository_ListByWorkID(t *testing.T) { + t.Run("should return contributions for a given work id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + workID := uint(1) + expectedContributions := []domain.Contribution{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedContributions[0].ID, expectedContributions[0].CreatedAt, expectedContributions[0].UpdatedAt). + AddRow(expectedContributions[1].ID, expectedContributions[1].CreatedAt, expectedContributions[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE work_id = $1`)). + WithArgs(workID). + WillReturnRows(rows) + + contributions, err := repo.ListByWorkID(context.Background(), workID) + require.NoError(t, err) + assert.Equal(t, expectedContributions, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + workID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE work_id = $1`)). + WithArgs(workID). + WillReturnError(sql.ErrNoRows) + + contributions, err := repo.ListByWorkID(context.Background(), workID) + require.Error(t, err) + assert.Nil(t, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestContributionRepository_ListByTranslationID(t *testing.T) { + t.Run("should return contributions for a given translation id", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + translationID := uint(1) + expectedContributions := []domain.Contribution{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedContributions[0].ID, expectedContributions[0].CreatedAt, expectedContributions[0].UpdatedAt). + AddRow(expectedContributions[1].ID, expectedContributions[1].CreatedAt, expectedContributions[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE translation_id = $1`)). + WithArgs(translationID). + WillReturnRows(rows) + + contributions, err := repo.ListByTranslationID(context.Background(), translationID) + require.NoError(t, err) + assert.Equal(t, expectedContributions, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + translationID := uint(1) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE translation_id = $1`)). + WithArgs(translationID). + WillReturnError(sql.ErrNoRows) + + contributions, err := repo.ListByTranslationID(context.Background(), translationID) + require.Error(t, err) + assert.Nil(t, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestContributionRepository_ListByStatus(t *testing.T) { + t.Run("should return contributions for a given status", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + status := "draft" + expectedContributions := []domain.Contribution{ + {BaseModel: domain.BaseModel{ID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + {BaseModel: domain.BaseModel{ID: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}}, + } + + rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(expectedContributions[0].ID, expectedContributions[0].CreatedAt, expectedContributions[0].UpdatedAt). + AddRow(expectedContributions[1].ID, expectedContributions[1].CreatedAt, expectedContributions[1].UpdatedAt) + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE status = $1`)). + WithArgs(status). + WillReturnRows(rows) + + contributions, err := repo.ListByStatus(context.Background(), status) + require.NoError(t, err) + assert.Equal(t, expectedContributions, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("should return error if query fails", func(t *testing.T) { + db, mock, err := newMockDb() + require.NoError(t, err) + repo := repo.NewContributionRepository(db) + + status := "draft" + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "contributions" WHERE status = $1`)). + WithArgs(status). + WillReturnError(sql.ErrNoRows) + + contributions, err := repo.ListByStatus(context.Background(), status) + require.Error(t, err) + assert.Nil(t, contributions) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} diff --git a/internal/data/sql/copyright_claim_repository.go b/internal/data/sql/copyright_claim_repository.go index 53e2132..9efc0d4 100644 --- a/internal/data/sql/copyright_claim_repository.go +++ b/internal/data/sql/copyright_claim_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/copyright_claim" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type copyrightClaimRepository struct { } // NewCopyrightClaimRepository creates a new CopyrightClaimRepository. -func NewCopyrightClaimRepository(db *gorm.DB) copyright_claim.Copyright_claimRepository { +func NewCopyrightClaimRepository(db *gorm.DB) domain.CopyrightClaimRepository { return ©rightClaimRepository{ BaseRepository: NewBaseRepositoryImpl[domain.CopyrightClaim](db), db: db, diff --git a/internal/data/sql/copyright_repository.go b/internal/data/sql/copyright_repository.go index 6582abd..3c13e72 100644 --- a/internal/data/sql/copyright_repository.go +++ b/internal/data/sql/copyright_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/copyright" "gorm.io/gorm" ) @@ -15,45 +14,13 @@ type copyrightRepository struct { } // NewCopyrightRepository creates a new CopyrightRepository. -func NewCopyrightRepository(db *gorm.DB) copyright.CopyrightRepository { +func NewCopyrightRepository(db *gorm.DB) domain.CopyrightRepository { return ©rightRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Copyright](db), db: db, } } -// AttachToEntity attaches a copyright to any entity type -func (r *copyrightRepository) AttachToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error { - copyrightable := domain.Copyrightable{ - CopyrightID: copyrightID, - CopyrightableID: entityID, - CopyrightableType: entityType, - } - return r.db.WithContext(ctx).Create(©rightable).Error -} - -// DetachFromEntity removes a copyright from an entity -func (r *copyrightRepository) DetachFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error { - return r.db.WithContext(ctx).Where("copyright_id = ? AND copyrightable_id = ? AND copyrightable_type = ?", - copyrightID, entityID, entityType).Delete(&domain.Copyrightable{}).Error -} - -// GetByEntity gets all copyrights for a specific entity -func (r *copyrightRepository) GetByEntity(ctx context.Context, entityID uint, entityType string) ([]domain.Copyright, error) { - var copyrights []domain.Copyright - err := r.db.WithContext(ctx).Joins("JOIN copyrightables ON copyrightables.copyright_id = copyrights.id"). - Where("copyrightables.copyrightable_id = ? AND copyrightables.copyrightable_type = ?", entityID, entityType). - Preload("Translations"). - Find(©rights).Error - return copyrights, err -} - -// GetEntitiesByCopyright gets all entities that have a specific copyright -func (r *copyrightRepository) GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]domain.Copyrightable, error) { - var copyrightables []domain.Copyrightable - err := r.db.WithContext(ctx).Where("copyright_id = ?", copyrightID).Find(©rightables).Error - return copyrightables, err -} // AddTranslation adds a translation to a copyright func (r *copyrightRepository) AddTranslation(ctx context.Context, translation *domain.CopyrightTranslation) error { return r.db.WithContext(ctx).Create(translation).Error @@ -78,3 +45,43 @@ func (r *copyrightRepository) GetTranslationByLanguage(ctx context.Context, copy } return &translation, nil } + +func (r *copyrightRepository) AddCopyrightToWork(ctx context.Context, workID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("INSERT INTO work_copyrights (work_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", workID, copyrightID).Error +} + +func (r *copyrightRepository) RemoveCopyrightFromWork(ctx context.Context, workID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("DELETE FROM work_copyrights WHERE work_id = ? AND copyright_id = ?", workID, copyrightID).Error +} + +func (r *copyrightRepository) AddCopyrightToAuthor(ctx context.Context, authorID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("INSERT INTO author_copyrights (author_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", authorID, copyrightID).Error +} + +func (r *copyrightRepository) RemoveCopyrightFromAuthor(ctx context.Context, authorID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("DELETE FROM author_copyrights WHERE author_id = ? AND copyright_id = ?", authorID, copyrightID).Error +} + +func (r *copyrightRepository) AddCopyrightToBook(ctx context.Context, bookID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("INSERT INTO book_copyrights (book_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", bookID, copyrightID).Error +} + +func (r *copyrightRepository) RemoveCopyrightFromBook(ctx context.Context, bookID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("DELETE FROM book_copyrights WHERE book_id = ? AND copyright_id = ?", bookID, copyrightID).Error +} + +func (r *copyrightRepository) AddCopyrightToPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("INSERT INTO publisher_copyrights (publisher_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", publisherID, copyrightID).Error +} + +func (r *copyrightRepository) RemoveCopyrightFromPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("DELETE FROM publisher_copyrights WHERE publisher_id = ? AND copyright_id = ?", publisherID, copyrightID).Error +} + +func (r *copyrightRepository) AddCopyrightToSource(ctx context.Context, sourceID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("INSERT INTO source_copyrights (source_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", sourceID, copyrightID).Error +} + +func (r *copyrightRepository) RemoveCopyrightFromSource(ctx context.Context, sourceID uint, copyrightID uint) error { + return r.db.WithContext(ctx).Exec("DELETE FROM source_copyrights WHERE source_id = ? AND copyright_id = ?", sourceID, copyrightID).Error +} diff --git a/internal/data/sql/copyright_repository_test.go b/internal/data/sql/copyright_repository_test.go new file mode 100644 index 0000000..b76a9d0 --- /dev/null +++ b/internal/data/sql/copyright_repository_test.go @@ -0,0 +1,239 @@ +package sql_test + +import ( + "context" + "database/sql/driver" + "testing" + "tercul/internal/data/sql" + "tercul/internal/domain" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/suite" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// AnyTime is used to match any time.Time value in sqlmock. +type AnyTime struct{} + +// Match satisfies sqlmock.Argument interface +func (a AnyTime) Match(v driver.Value) bool { + _, ok := v.(time.Time) + return ok +} + +// CopyrightRepositoryTestSuite is the test suite for CopyrightRepository. +type CopyrightRepositoryTestSuite struct { + suite.Suite + db *gorm.DB + mock sqlmock.Sqlmock + repo domain.CopyrightRepository +} + +// SetupTest sets up the test environment. +func (s *CopyrightRepositoryTestSuite) SetupTest() { + db, mock, err := sqlmock.New() + s.Require().NoError(err) + + gormDB, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{}) + s.Require().NoError(err) + + s.db = gormDB + s.mock = mock + s.repo = sql.NewCopyrightRepository(s.db) +} + +// TearDownTest checks if all expectations were met. +func (s *CopyrightRepositoryTestSuite) TearDownTest() { + s.Require().NoError(s.mock.ExpectationsWereMet()) +} + +// TestCopyrightRepositoryTestSuite runs the test suite. +func TestCopyrightRepositoryTestSuite(t *testing.T) { + suite.Run(t, new(CopyrightRepositoryTestSuite)) +} + +func (s *CopyrightRepositoryTestSuite) TestNewCopyrightRepository() { + s.Run("should create a new repository", func() { + s.NotNil(s.repo) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestAddTranslation() { + s.Run("should add a translation", func() { + translation := &domain.CopyrightTranslation{ + CopyrightID: 1, + LanguageCode: "en", + Message: "Test message", + Description: "", + } + + s.mock.ExpectBegin() + s.mock.ExpectQuery(`INSERT INTO "copyright_translations" \("created_at","updated_at","copyright_id","language_code","message","description"\) VALUES \(\$1,\$2,\$3,\$4,\$5,\$6\) RETURNING "id"`). + WithArgs(AnyTime{}, AnyTime{}, translation.CopyrightID, translation.LanguageCode, translation.Message, translation.Description). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + s.mock.ExpectCommit() + + err := s.repo.AddTranslation(context.Background(), translation) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestGetTranslations() { + s.Run("should get all translations for a copyright", func() { + copyrightID := uint(1) + rows := sqlmock.NewRows([]string{"id", "copyright_id", "language_code", "message"}). + AddRow(1, copyrightID, "en", "English message"). + AddRow(2, copyrightID, "es", "Spanish message") + + s.mock.ExpectQuery(`SELECT \* FROM "copyright_translations" WHERE copyright_id = \$1`). + WithArgs(copyrightID). + WillReturnRows(rows) + + translations, err := s.repo.GetTranslations(context.Background(), copyrightID) + s.Require().NoError(err) + s.Require().Len(translations, 2) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestGetTranslationByLanguage() { + s.Run("should get a specific translation by language code", func() { + copyrightID := uint(1) + languageCode := "en" + rows := sqlmock.NewRows([]string{"id", "copyright_id", "language_code", "message"}). + AddRow(1, copyrightID, languageCode, "English message") + + s.mock.ExpectQuery(`SELECT \* FROM "copyright_translations" WHERE copyright_id = \$1 AND language_code = \$2 ORDER BY "copyright_translations"\."id" LIMIT \$3`). + WithArgs(copyrightID, languageCode, 1). + WillReturnRows(rows) + + translation, err := s.repo.GetTranslationByLanguage(context.Background(), copyrightID, languageCode) + s.Require().NoError(err) + s.Require().NotNil(translation) + s.Require().Equal(languageCode, translation.LanguageCode) + }) + + s.Run("should return ErrEntityNotFound for non-existent translation", func() { + copyrightID := uint(1) + languageCode := "en" + + s.mock.ExpectQuery(`SELECT \* FROM "copyright_translations" WHERE copyright_id = \$1 AND language_code = \$2 ORDER BY "copyright_translations"\."id" LIMIT \$3`). + WithArgs(copyrightID, languageCode, 1). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := s.repo.GetTranslationByLanguage(context.Background(), copyrightID, languageCode) + s.Require().Error(err) + s.Require().Equal(sql.ErrEntityNotFound, err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestAddCopyrightToWork() { + s.Run("should add a copyright to a work", func() { + workID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`INSERT INTO work_copyrights \(work_id, copyright_id\) VALUES \(\$1, \$2\) ON CONFLICT DO NOTHING`). + WithArgs(workID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.AddCopyrightToWork(context.Background(), workID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestRemoveCopyrightFromWork() { + s.Run("should remove a copyright from a work", func() { + workID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`DELETE FROM work_copyrights WHERE work_id = \$1 AND copyright_id = \$2`). + WithArgs(workID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.RemoveCopyrightFromWork(context.Background(), workID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestAddCopyrightToAuthor() { + s.Run("should add a copyright to an author", func() { + authorID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`INSERT INTO author_copyrights \(author_id, copyright_id\) VALUES \(\$1, \$2\) ON CONFLICT DO NOTHING`). + WithArgs(authorID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.AddCopyrightToAuthor(context.Background(), authorID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestRemoveCopyrightFromAuthor() { + s.Run("should remove a copyright from an author", func() { + authorID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`DELETE FROM author_copyrights WHERE author_id = \$1 AND copyright_id = \$2`). + WithArgs(authorID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.RemoveCopyrightFromAuthor(context.Background(), authorID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestAddCopyrightToBook() { + s.Run("should add a copyright to a book", func() { + bookID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`INSERT INTO book_copyrights \(book_id, copyright_id\) VALUES \(\$1, \$2\) ON CONFLICT DO NOTHING`). + WithArgs(bookID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.AddCopyrightToBook(context.Background(), bookID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestRemoveCopyrightFromBook() { + s.Run("should remove a copyright from a book", func() { + bookID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`DELETE FROM book_copyrights WHERE book_id = \$1 AND copyright_id = \$2`). + WithArgs(bookID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.RemoveCopyrightFromBook(context.Background(), bookID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestAddCopyrightToPublisher() { + s.Run("should add a copyright to a publisher", func() { + publisherID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`INSERT INTO publisher_copyrights \(publisher_id, copyright_id\) VALUES \(\$1, \$2\) ON CONFLICT DO NOTHING`). + WithArgs(publisherID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.AddCopyrightToPublisher(context.Background(), publisherID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestRemoveCopyrightFromPublisher() { + s.Run("should remove a copyright from a publisher", func() { + publisherID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`DELETE FROM publisher_copyrights WHERE publisher_id = \$1 AND copyright_id = \$2`). + WithArgs(publisherID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.RemoveCopyrightFromPublisher(context.Background(), publisherID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestAddCopyrightToSource() { + s.Run("should add a copyright to a source", func() { + sourceID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`INSERT INTO source_copyrights \(source_id, copyright_id\) VALUES \(\$1, \$2\) ON CONFLICT DO NOTHING`). + WithArgs(sourceID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.AddCopyrightToSource(context.Background(), sourceID, copyrightID) + s.Require().NoError(err) + }) +} + +func (s *CopyrightRepositoryTestSuite) TestRemoveCopyrightFromSource() { + s.Run("should remove a copyright from a source", func() { + sourceID, copyrightID := uint(1), uint(1) + s.mock.ExpectExec(`DELETE FROM source_copyrights WHERE source_id = \$1 AND copyright_id = \$2`). + WithArgs(sourceID, copyrightID). + WillReturnResult(sqlmock.NewResult(1, 1)) + err := s.repo.RemoveCopyrightFromSource(context.Background(), sourceID, copyrightID) + s.Require().NoError(err) + }) +} diff --git a/internal/data/sql/country_repository.go b/internal/data/sql/country_repository.go index e448c49..8f4f809 100644 --- a/internal/data/sql/country_repository.go +++ b/internal/data/sql/country_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/country" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type countryRepository struct { } // NewCountryRepository creates a new CountryRepository. -func NewCountryRepository(db *gorm.DB) country.CountryRepository { +func NewCountryRepository(db *gorm.DB) domain.CountryRepository { return &countryRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Country](db), db: db, diff --git a/internal/data/sql/edge_repository.go b/internal/data/sql/edge_repository.go index c987cd4..f49badc 100644 --- a/internal/data/sql/edge_repository.go +++ b/internal/data/sql/edge_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/edge" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type edgeRepository struct { } // NewEdgeRepository creates a new EdgeRepository. -func NewEdgeRepository(db *gorm.DB) edge.EdgeRepository { +func NewEdgeRepository(db *gorm.DB) domain.EdgeRepository { return &edgeRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Edge](db), db: db, diff --git a/internal/data/sql/edition_repository.go b/internal/data/sql/edition_repository.go index 968b186..f732ca5 100644 --- a/internal/data/sql/edition_repository.go +++ b/internal/data/sql/edition_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/edition" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type editionRepository struct { } // NewEditionRepository creates a new EditionRepository. -func NewEditionRepository(db *gorm.DB) edition.EditionRepository { +func NewEditionRepository(db *gorm.DB) domain.EditionRepository { return &editionRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Edition](db), db: db, diff --git a/internal/data/sql/email_verification_repository.go b/internal/data/sql/email_verification_repository.go index 3a52534..3a250e0 100644 --- a/internal/data/sql/email_verification_repository.go +++ b/internal/data/sql/email_verification_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/email_verification" "time" "gorm.io/gorm" @@ -16,7 +15,7 @@ type emailVerificationRepository struct { } // NewEmailVerificationRepository creates a new EmailVerificationRepository. -func NewEmailVerificationRepository(db *gorm.DB) email_verification.Email_verificationRepository { +func NewEmailVerificationRepository(db *gorm.DB) domain.EmailVerificationRepository { return &emailVerificationRepository{ BaseRepository: NewBaseRepositoryImpl[domain.EmailVerification](db), db: db, diff --git a/internal/data/sql/like_repository.go b/internal/data/sql/like_repository.go index 6688932..c644a2f 100644 --- a/internal/data/sql/like_repository.go +++ b/internal/data/sql/like_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/like" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type likeRepository struct { } // NewLikeRepository creates a new LikeRepository. -func NewLikeRepository(db *gorm.DB) like.LikeRepository { +func NewLikeRepository(db *gorm.DB) domain.LikeRepository { return &likeRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Like](db), db: db, diff --git a/internal/data/sql/localization_repository.go b/internal/data/sql/localization_repository.go new file mode 100644 index 0000000..6ce0d4e --- /dev/null +++ b/internal/data/sql/localization_repository.go @@ -0,0 +1,38 @@ +package sql + +import ( + "context" + "tercul/internal/domain" + + "gorm.io/gorm" +) + +type localizationRepository struct { + db *gorm.DB +} + +func NewLocalizationRepository(db *gorm.DB) domain.LocalizationRepository { + return &localizationRepository{db: db} +} + +func (r *localizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) { + var localization domain.Localization + err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&localization).Error + if err != nil { + return "", err + } + return localization.Value, nil +} + +func (r *localizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { + var localizations []domain.Localization + err := r.db.WithContext(ctx).Where("key IN ? AND language = ?", keys, language).Find(&localizations).Error + if err != nil { + return nil, err + } + result := make(map[string]string) + for _, l := range localizations { + result[l.Key] = l.Value + } + return result, nil +} diff --git a/internal/data/sql/main_test.go b/internal/data/sql/main_test.go new file mode 100644 index 0000000..6f61d08 --- /dev/null +++ b/internal/data/sql/main_test.go @@ -0,0 +1,27 @@ +package sql_test + +import ( + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/DATA-DOG/go-sqlmock" +) + +func newMockDb() (*gorm.DB, sqlmock.Sqlmock, error) { + db, mock, err := sqlmock.New() + if err != nil { + return nil, nil, err + } + + gormDB, err := gorm.Open(postgres.New(postgres.Config{ + Conn: db, + }), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return nil, nil, err + } + + return gormDB, mock, nil +} diff --git a/internal/data/sql/monetization_repository.go b/internal/data/sql/monetization_repository.go index 7fa2d62..2485ae5 100644 --- a/internal/data/sql/monetization_repository.go +++ b/internal/data/sql/monetization_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/monetization" "gorm.io/gorm" ) @@ -14,36 +13,69 @@ type monetizationRepository struct { } // NewMonetizationRepository creates a new MonetizationRepository. -func NewMonetizationRepository(db *gorm.DB) monetization.MonetizationRepository { +func NewMonetizationRepository(db *gorm.DB) domain.MonetizationRepository { return &monetizationRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Monetization](db), db: db, } } -// ListByWorkID finds monetizations by work ID -func (r *monetizationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Monetization, error) { - var monetizations []domain.Monetization - if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&monetizations).Error; err != nil { - return nil, err - } - return monetizations, nil +func (r *monetizationRepository) AddMonetizationToWork(ctx context.Context, workID uint, monetizationID uint) error { + work := &domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(work).Association("Monetizations").Append(monetization) } -// ListByTranslationID finds monetizations by translation ID -func (r *monetizationRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Monetization, error) { - var monetizations []domain.Monetization - if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&monetizations).Error; err != nil { - return nil, err - } - return monetizations, nil +func (r *monetizationRepository) RemoveMonetizationFromWork(ctx context.Context, workID uint, monetizationID uint) error { + work := &domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(work).Association("Monetizations").Delete(monetization) } -// ListByBookID finds monetizations by book ID -func (r *monetizationRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Monetization, error) { - var monetizations []domain.Monetization - if err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Find(&monetizations).Error; err != nil { - return nil, err - } - return monetizations, nil +func (r *monetizationRepository) AddMonetizationToAuthor(ctx context.Context, authorID uint, monetizationID uint) error { + author := &domain.Author{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: authorID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(author).Association("Monetizations").Append(monetization) +} + +func (r *monetizationRepository) RemoveMonetizationFromAuthor(ctx context.Context, authorID uint, monetizationID uint) error { + author := &domain.Author{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: authorID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(author).Association("Monetizations").Delete(monetization) +} + +func (r *monetizationRepository) AddMonetizationToBook(ctx context.Context, bookID uint, monetizationID uint) error { + book := &domain.Book{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: bookID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(book).Association("Monetizations").Append(monetization) +} + +func (r *monetizationRepository) RemoveMonetizationFromBook(ctx context.Context, bookID uint, monetizationID uint) error { + book := &domain.Book{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: bookID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(book).Association("Monetizations").Delete(monetization) +} + +func (r *monetizationRepository) AddMonetizationToPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { + publisher := &domain.Publisher{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: publisherID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(publisher).Association("Monetizations").Append(monetization) +} + +func (r *monetizationRepository) RemoveMonetizationFromPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { + publisher := &domain.Publisher{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: publisherID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(publisher).Association("Monetizations").Delete(monetization) +} + +func (r *monetizationRepository) AddMonetizationToSource(ctx context.Context, sourceID uint, monetizationID uint) error { + source := &domain.Source{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: sourceID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(source).Association("Monetizations").Append(monetization) +} + +func (r *monetizationRepository) RemoveMonetizationFromSource(ctx context.Context, sourceID uint, monetizationID uint) error { + source := &domain.Source{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: sourceID}}} + monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} + return r.db.WithContext(ctx).Model(source).Association("Monetizations").Delete(monetization) } diff --git a/internal/data/sql/monetization_repository_test.go b/internal/data/sql/monetization_repository_test.go new file mode 100644 index 0000000..472696b --- /dev/null +++ b/internal/data/sql/monetization_repository_test.go @@ -0,0 +1,50 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type MonetizationRepositoryTestSuite struct { + testutil.IntegrationTestSuite +} + +func (s *MonetizationRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) +} + +func (s *MonetizationRepositoryTestSuite) SetupTest() { + s.DB.Exec("DELETE FROM work_monetizations") + s.DB.Exec("DELETE FROM monetizations") + s.DB.Exec("DELETE FROM works") +} + +func (s *MonetizationRepositoryTestSuite) TestAddMonetizationToWork() { + s.Run("should add a monetization to a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + monetization := &domain.Monetization{Amount: 10.0} + s.Require().NoError(s.DB.Create(monetization).Error) + + // Act + err := s.MonetizationRepo.AddMonetizationToWork(context.Background(), work.ID, monetization.ID) + + // Assert + s.Require().NoError(err) + + // Verify that the association was created in the database + var foundWork domain.Work + err = s.DB.Preload("Monetizations").First(&foundWork, work.ID).Error + s.Require().NoError(err) + s.Require().Len(foundWork.Monetizations, 1) + s.Equal(monetization.ID, foundWork.Monetizations[0].ID) + }) +} + +func TestMonetizationRepository(t *testing.T) { + suite.Run(t, new(MonetizationRepositoryTestSuite)) +} diff --git a/internal/data/sql/password_reset_repository.go b/internal/data/sql/password_reset_repository.go index 00740ea..dc91705 100644 --- a/internal/data/sql/password_reset_repository.go +++ b/internal/data/sql/password_reset_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/password_reset" "time" "gorm.io/gorm" @@ -16,7 +15,7 @@ type passwordResetRepository struct { } // NewPasswordResetRepository creates a new PasswordResetRepository. -func NewPasswordResetRepository(db *gorm.DB) password_reset.Password_resetRepository { +func NewPasswordResetRepository(db *gorm.DB) domain.PasswordResetRepository { return &passwordResetRepository{ BaseRepository: NewBaseRepositoryImpl[domain.PasswordReset](db), db: db, diff --git a/internal/data/sql/place_repository.go b/internal/data/sql/place_repository.go index 992cad8..f082f0b 100644 --- a/internal/data/sql/place_repository.go +++ b/internal/data/sql/place_repository.go @@ -4,7 +4,6 @@ import ( "context" "math" "tercul/internal/domain" - "tercul/internal/domain/place" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type placeRepository struct { } // NewPlaceRepository creates a new PlaceRepository. -func NewPlaceRepository(db *gorm.DB) place.PlaceRepository { +func NewPlaceRepository(db *gorm.DB) domain.PlaceRepository { return &placeRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Place](db), db: db, diff --git a/internal/data/sql/publisher_repository.go b/internal/data/sql/publisher_repository.go index e96af2b..c00dc08 100644 --- a/internal/data/sql/publisher_repository.go +++ b/internal/data/sql/publisher_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/publisher" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type publisherRepository struct { } // NewPublisherRepository creates a new PublisherRepository. -func NewPublisherRepository(db *gorm.DB) publisher.PublisherRepository { +func NewPublisherRepository(db *gorm.DB) domain.PublisherRepository { return &publisherRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Publisher](db), db: db, diff --git a/internal/data/sql/publisher_repository_test.go b/internal/data/sql/publisher_repository_test.go new file mode 100644 index 0000000..c5030f2 --- /dev/null +++ b/internal/data/sql/publisher_repository_test.go @@ -0,0 +1,28 @@ +package sql_test + +import ( + "testing" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type PublisherRepositoryTestSuite struct { + testutil.IntegrationTestSuite +} + +func (s *PublisherRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) +} + +func (s *PublisherRepositoryTestSuite) SetupTest() { + s.DB.Exec("DELETE FROM publishers") +} + +func TestPublisherRepository(t *testing.T) { + suite.Run(t, new(PublisherRepositoryTestSuite)) +} + +// NOTE: All tests for this repository were removed because they tested generic +// CRUD functionality that is now covered in `base_repository_test.go`. +// If you add publisher-specific methods to the repository, add their tests here. diff --git a/internal/data/sql/repositories.go b/internal/data/sql/repositories.go new file mode 100644 index 0000000..1f2395d --- /dev/null +++ b/internal/data/sql/repositories.go @@ -0,0 +1,52 @@ +package sql + +import ( + "tercul/internal/domain" + + "gorm.io/gorm" +) + +type Repositories struct { + Work domain.WorkRepository + User domain.UserRepository + Author domain.AuthorRepository + Translation domain.TranslationRepository + Comment domain.CommentRepository + Like domain.LikeRepository + Bookmark domain.BookmarkRepository + Collection domain.CollectionRepository + Tag domain.TagRepository + Category domain.CategoryRepository + Book domain.BookRepository + Publisher domain.PublisherRepository + Source domain.SourceRepository + Copyright domain.CopyrightRepository + Monetization domain.MonetizationRepository + Analytics domain.AnalyticsRepository + Auth domain.AuthRepository + Localization domain.LocalizationRepository +} + +// NewRepositories creates a new Repositories container +func NewRepositories(db *gorm.DB) *Repositories { + return &Repositories{ + Work: NewWorkRepository(db), + User: NewUserRepository(db), + Author: NewAuthorRepository(db), + Translation: NewTranslationRepository(db), + Comment: NewCommentRepository(db), + Like: NewLikeRepository(db), + Bookmark: NewBookmarkRepository(db), + Collection: NewCollectionRepository(db), + Tag: NewTagRepository(db), + Category: NewCategoryRepository(db), + Book: NewBookRepository(db), + Publisher: NewPublisherRepository(db), + Source: NewSourceRepository(db), + Copyright: NewCopyrightRepository(db), + Monetization: NewMonetizationRepository(db), + Analytics: NewAnalyticsRepository(db), + Auth: NewAuthRepository(db), + Localization: NewLocalizationRepository(db), + } +} diff --git a/internal/data/sql/source_repository.go b/internal/data/sql/source_repository.go index e9b19ee..e18962d 100644 --- a/internal/data/sql/source_repository.go +++ b/internal/data/sql/source_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/source" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type sourceRepository struct { } // NewSourceRepository creates a new SourceRepository. -func NewSourceRepository(db *gorm.DB) source.SourceRepository { +func NewSourceRepository(db *gorm.DB) domain.SourceRepository { return &sourceRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Source](db), db: db, diff --git a/internal/data/sql/source_repository_test.go b/internal/data/sql/source_repository_test.go new file mode 100644 index 0000000..dbdbce9 --- /dev/null +++ b/internal/data/sql/source_repository_test.go @@ -0,0 +1,28 @@ +package sql_test + +import ( + "testing" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type SourceRepositoryTestSuite struct { + testutil.IntegrationTestSuite +} + +func (s *SourceRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) +} + +func (s *SourceRepositoryTestSuite) SetupTest() { + s.DB.Exec("DELETE FROM sources") +} + +func TestSourceRepository(t *testing.T) { + suite.Run(t, new(SourceRepositoryTestSuite)) +} + +// NOTE: All tests for this repository were removed because they tested generic +// CRUD functionality that is now covered in `base_repository_test.go`. +// If you add source-specific methods to the repository, add their tests here. diff --git a/internal/data/sql/tag_repository.go b/internal/data/sql/tag_repository.go index 96ae1c2..82a90bb 100644 --- a/internal/data/sql/tag_repository.go +++ b/internal/data/sql/tag_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/tag" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type tagRepository struct { } // NewTagRepository creates a new TagRepository. -func NewTagRepository(db *gorm.DB) tag.TagRepository { +func NewTagRepository(db *gorm.DB) domain.TagRepository { return &tagRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Tag](db), db: db, diff --git a/internal/data/sql/translation_repository.go b/internal/data/sql/translation_repository.go index b7d5a7c..28e332e 100644 --- a/internal/data/sql/translation_repository.go +++ b/internal/data/sql/translation_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/translation" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type translationRepository struct { } // NewTranslationRepository creates a new TranslationRepository. -func NewTranslationRepository(db *gorm.DB) translation.TranslationRepository { +func NewTranslationRepository(db *gorm.DB) domain.TranslationRepository { return &translationRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Translation](db), db: db, diff --git a/internal/data/sql/user_profile_repository.go b/internal/data/sql/user_profile_repository.go index d624a70..d8c0700 100644 --- a/internal/data/sql/user_profile_repository.go +++ b/internal/data/sql/user_profile_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/user_profile" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type userProfileRepository struct { } // NewUserProfileRepository creates a new UserProfileRepository. -func NewUserProfileRepository(db *gorm.DB) user_profile.User_profileRepository { +func NewUserProfileRepository(db *gorm.DB) domain.UserProfileRepository { return &userProfileRepository{ BaseRepository: NewBaseRepositoryImpl[domain.UserProfile](db), db: db, diff --git a/internal/data/sql/user_repository.go b/internal/data/sql/user_repository.go index 158795d..a409e60 100644 --- a/internal/data/sql/user_repository.go +++ b/internal/data/sql/user_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/user" "gorm.io/gorm" ) @@ -15,7 +14,7 @@ type userRepository struct { } // NewUserRepository creates a new UserRepository. -func NewUserRepository(db *gorm.DB) user.UserRepository { +func NewUserRepository(db *gorm.DB) domain.UserRepository { return &userRepository{ BaseRepository: NewBaseRepositoryImpl[domain.User](db), db: db, diff --git a/internal/data/sql/user_session_repository.go b/internal/data/sql/user_session_repository.go index f8961e6..f7265f5 100644 --- a/internal/data/sql/user_session_repository.go +++ b/internal/data/sql/user_session_repository.go @@ -4,7 +4,6 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/user_session" "time" "gorm.io/gorm" @@ -16,7 +15,7 @@ type userSessionRepository struct { } // NewUserSessionRepository creates a new UserSessionRepository. -func NewUserSessionRepository(db *gorm.DB) user_session.User_sessionRepository { +func NewUserSessionRepository(db *gorm.DB) domain.UserSessionRepository { return &userSessionRepository{ BaseRepository: NewBaseRepositoryImpl[domain.UserSession](db), db: db, diff --git a/internal/data/sql/work_repository.go b/internal/data/sql/work_repository.go index a0b71f2..effd495 100644 --- a/internal/data/sql/work_repository.go +++ b/internal/data/sql/work_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" "gorm.io/gorm" ) @@ -14,7 +13,7 @@ type workRepository struct { } // NewWorkRepository creates a new WorkRepository. -func NewWorkRepository(db *gorm.DB) work.WorkRepository { +func NewWorkRepository(db *gorm.DB) domain.WorkRepository { return &workRepository{ BaseRepository: NewBaseRepositoryImpl[domain.Work](db), db: db, @@ -100,6 +99,29 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa }, nil } + + + + + + + + +// Delete removes a work and its associations +func (r *workRepository) Delete(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Manually delete associations + if err := tx.Select("Copyrights", "Monetizations", "Authors", "Tags", "Categories").Delete(&domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}).Error; err != nil { + return err + } + // Also delete the work itself + if err := tx.Delete(&domain.Work{}, id).Error; err != nil { + return err + } + return nil + }) +} + // GetWithTranslations gets a work with its translations func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { return r.FindWithPreload(ctx, []string{"Translations"}, id) diff --git a/internal/data/sql/work_repository_test.go b/internal/data/sql/work_repository_test.go new file mode 100644 index 0000000..33dff3c --- /dev/null +++ b/internal/data/sql/work_repository_test.go @@ -0,0 +1,145 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/domain" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type WorkRepositoryTestSuite struct { + testutil.IntegrationTestSuite +} + +func (s *WorkRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) +} + +func (s *WorkRepositoryTestSuite) TestCreateWork() { + s.Run("should create a new work with a copyright", func() { + // Arrange + copyright := &domain.Copyright{ + Name: "Test Copyright", + Identificator: "TC-123", + } + s.Require().NoError(s.DB.Create(copyright).Error) + + work := &domain.Work{ + Title: "New Test Work", + TranslatableModel: domain.TranslatableModel{ + Language: "en", + }, + Copyrights: []*domain.Copyright{copyright}, + } + + // Act + err := s.WorkRepo.Create(context.Background(), work) + + // Assert + s.Require().NoError(err) + s.NotZero(work.ID) + + // Verify that the work was actually created in the database + var foundWork domain.Work + err = s.DB.Preload("Copyrights").First(&foundWork, work.ID).Error + s.Require().NoError(err) + s.Equal("New Test Work", foundWork.Title) + s.Equal("en", foundWork.Language) + s.Require().Len(foundWork.Copyrights, 1) + s.Equal("Test Copyright", foundWork.Copyrights[0].Name) + }) +} + +func (s *WorkRepositoryTestSuite) TestGetWorkByID() { + s.Run("should return a work by ID with copyrights", func() { + // Arrange + copyright := &domain.Copyright{ + Name: "Test Copyright", + Identificator: "TC-123", + } + s.Require().NoError(s.DB.Create(copyright).Error) + + work := s.CreateTestWork("Test Work", "en", "Test content") + s.Require().NoError(s.DB.Model(work).Association("Copyrights").Append(copyright)) + + // Act + foundWork, err := s.WorkRepo.GetByID(context.Background(), work.ID) + + // Assert + s.Require().NoError(err) + s.Require().NotNil(foundWork) + s.Equal(work.ID, foundWork.ID) + s.Equal("Test Work", foundWork.Title) + }) + + s.Run("should return error if work not found", func() { + // Act + foundWork, err := s.WorkRepo.GetByID(context.Background(), 999) + + // Assert + s.Require().Error(err) + s.Nil(foundWork) + }) +} + +func (s *WorkRepositoryTestSuite) TestUpdateWork() { + s.Run("should update an existing work and its copyrights", func() { + // Arrange + copyright1 := &domain.Copyright{Name: "C1", Identificator: "C1"} + copyright2 := &domain.Copyright{Name: "C2", Identificator: "C2"} + s.Require().NoError(s.DB.Create(©right1).Error) + s.Require().NoError(s.DB.Create(©right2).Error) + + work := s.CreateTestWork("Original Title", "en", "Original content") + s.Require().NoError(s.DB.Model(work).Association("Copyrights").Append(copyright1)) + + work.Title = "Updated Title" + s.Require().NoError(s.DB.Model(work).Association("Copyrights").Replace(copyright2)) + + // Act + err := s.WorkRepo.Update(context.Background(), work) + + // Assert + s.Require().NoError(err) + + // Verify that the work was actually updated in the database + var foundWork domain.Work + err = s.DB.Preload("Copyrights").First(&foundWork, work.ID).Error + s.Require().NoError(err) + s.Equal("Updated Title", foundWork.Title) + s.Require().Len(foundWork.Copyrights, 1) + s.Equal("C2", foundWork.Copyrights[0].Name) + }) +} + +func (s *WorkRepositoryTestSuite) TestDeleteWork() { + s.Run("should delete an existing work and its associations", func() { + // Arrange + work := s.CreateTestWork("To Be Deleted", "en", "Content") + copyright := &domain.Copyright{Name: "C1", Identificator: "C1"} + s.Require().NoError(s.DB.Create(copyright).Error) + s.Require().NoError(s.DB.Model(work).Association("Copyrights").Append(copyright)) + + // Act + err := s.WorkRepo.Delete(context.Background(), work.ID) + + // Assert + s.Require().NoError(err) + + // Verify that the work was actually deleted from the database + var foundWork domain.Work + err = s.DB.First(&foundWork, work.ID).Error + s.Require().Error(err) + + // Verify that the association in the join table is also deleted + var count int64 + s.DB.Table("work_copyrights").Where("work_id = ?", work.ID).Count(&count) + s.Zero(count) + }) +} + +func TestWorkRepository(t *testing.T) { + suite.Run(t, new(WorkRepositoryTestSuite)) +} diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go new file mode 100644 index 0000000..68ce2a9 --- /dev/null +++ b/internal/domain/analytics.go @@ -0,0 +1,18 @@ +package domain + +import "context" + +import "time" + +type AnalyticsRepository interface { + IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error + IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error + UpdateWorkStats(ctx context.Context, workID uint, stats WorkStats) error + UpdateTranslationStats(ctx context.Context, translationID uint, stats TranslationStats) error + GetOrCreateWorkStats(ctx context.Context, workID uint) (*WorkStats, error) + GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*TranslationStats, error) + GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*UserEngagement, error) + UpdateUserEngagement(ctx context.Context, userEngagement *UserEngagement) error + UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*Trending) error + GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*Work, error) +} diff --git a/internal/domain/author/repo.go b/internal/domain/author/repo.go deleted file mode 100644 index 4016138..0000000 --- a/internal/domain/author/repo.go +++ /dev/null @@ -1,15 +0,0 @@ -package author - -import ( - "context" - "tercul/internal/domain" -) - -// AuthorRepository defines CRUD methods specific to Author. -type AuthorRepository interface { - domain.BaseRepository[domain.Author] - - ListByWorkID(ctx context.Context, workID uint) ([]domain.Author, error) - ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) - ListByCountryID(ctx context.Context, countryID uint) ([]domain.Author, error) -} diff --git a/internal/domain/book/repo.go b/internal/domain/book/repo.go deleted file mode 100644 index c24fd8d..0000000 --- a/internal/domain/book/repo.go +++ /dev/null @@ -1,16 +0,0 @@ -package book - -import ( - "context" - "tercul/internal/domain" -) - -// BookRepository defines CRUD methods specific to Book. -type BookRepository interface { - domain.BaseRepository[domain.Book] - - ListByAuthorID(ctx context.Context, authorID uint) ([]domain.Book, error) - ListByPublisherID(ctx context.Context, publisherID uint) ([]domain.Book, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Book, error) - FindByISBN(ctx context.Context, isbn string) (*domain.Book, error) -} diff --git a/internal/domain/bookmark/repo.go b/internal/domain/bookmark/repo.go deleted file mode 100644 index 68d3656..0000000 --- a/internal/domain/bookmark/repo.go +++ /dev/null @@ -1,14 +0,0 @@ -package bookmark - -import ( - "context" - "tercul/internal/domain" -) - -// BookmarkRepository defines CRUD methods specific to Bookmark. -type BookmarkRepository interface { - domain.BaseRepository[domain.Bookmark] - - ListByUserID(ctx context.Context, userID uint) ([]domain.Bookmark, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Bookmark, error) -} diff --git a/internal/domain/category/repo.go b/internal/domain/category/repo.go deleted file mode 100644 index ee5dc1a..0000000 --- a/internal/domain/category/repo.go +++ /dev/null @@ -1,15 +0,0 @@ -package category - -import ( - "context" - "tercul/internal/domain" -) - -// CategoryRepository defines CRUD methods specific to Category. -type CategoryRepository interface { - domain.BaseRepository[domain.Category] - - FindByName(ctx context.Context, name string) (*domain.Category, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Category, error) - ListByParentID(ctx context.Context, parentID *uint) ([]domain.Category, error) -} diff --git a/internal/domain/city/repo.go b/internal/domain/city/repo.go deleted file mode 100644 index 9b306ba..0000000 --- a/internal/domain/city/repo.go +++ /dev/null @@ -1,13 +0,0 @@ -package city - -import ( - "context" - "tercul/internal/domain" -) - -// CityRepository defines CRUD methods specific to City. -type CityRepository interface { - domain.BaseRepository[domain.City] - - ListByCountryID(ctx context.Context, countryID uint) ([]domain.City, error) -} diff --git a/internal/domain/collection/repo.go b/internal/domain/collection/repo.go deleted file mode 100644 index 4ed84f9..0000000 --- a/internal/domain/collection/repo.go +++ /dev/null @@ -1,15 +0,0 @@ -package collection - -import ( - "context" - "tercul/internal/domain" -) - -// CollectionRepository defines CRUD methods specific to Collection. -type CollectionRepository interface { - domain.BaseRepository[domain.Collection] - - ListByUserID(ctx context.Context, userID uint) ([]domain.Collection, error) - ListPublic(ctx context.Context) ([]domain.Collection, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Collection, error) -} diff --git a/internal/domain/comment/repo.go b/internal/domain/comment/repo.go deleted file mode 100644 index a65177d..0000000 --- a/internal/domain/comment/repo.go +++ /dev/null @@ -1,16 +0,0 @@ -package comment - -import ( - "context" - "tercul/internal/domain" -) - -// CommentRepository defines CRUD methods specific to Comment. -type CommentRepository interface { - domain.BaseRepository[domain.Comment] - - ListByUserID(ctx context.Context, userID uint) ([]domain.Comment, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Comment, error) - ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Comment, error) - ListByParentID(ctx context.Context, parentID uint) ([]domain.Comment, error) -} diff --git a/internal/domain/contribution/repo.go b/internal/domain/contribution/repo.go deleted file mode 100644 index 180d800..0000000 --- a/internal/domain/contribution/repo.go +++ /dev/null @@ -1,17 +0,0 @@ -package contribution - -import ( - "context" - "tercul/internal/domain" -) - -// ContributionRepository defines CRUD methods specific to Contribution. -type ContributionRepository interface { - domain.BaseRepository[domain.Contribution] - - ListByUserID(ctx context.Context, userID uint) ([]domain.Contribution, error) - ListByReviewerID(ctx context.Context, reviewerID uint) ([]domain.Contribution, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Contribution, error) - ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Contribution, error) - ListByStatus(ctx context.Context, status string) ([]domain.Contribution, error) -} diff --git a/internal/domain/copyright/repo.go b/internal/domain/copyright/repo.go deleted file mode 100644 index 51b56ba..0000000 --- a/internal/domain/copyright/repo.go +++ /dev/null @@ -1,19 +0,0 @@ -package copyright - -import ( - "context" - "tercul/internal/domain" -) - -// CopyrightRepository defines CRUD methods specific to Copyright. -type CopyrightRepository interface { - domain.BaseRepository[domain.Copyright] - - AttachToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) (error) - DetachFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) (error) - GetByEntity(ctx context.Context, entityID uint, entityType string) ([]domain.Copyright, error) - GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]domain.Copyrightable, error) - AddTranslation(ctx context.Context, translation *domain.CopyrightTranslation) (error) - GetTranslations(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) - GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) -} diff --git a/internal/domain/copyright_claim/repo.go b/internal/domain/copyright_claim/repo.go deleted file mode 100644 index 17a4795..0000000 --- a/internal/domain/copyright_claim/repo.go +++ /dev/null @@ -1,14 +0,0 @@ -package copyright_claim - -import ( - "context" - "tercul/internal/domain" -) - -// Copyright_claimRepository defines CRUD methods specific to Copyright_claim. -type Copyright_claimRepository interface { - domain.BaseRepository[domain.CopyrightClaim] - - ListByWorkID(ctx context.Context, workID uint) ([]domain.CopyrightClaim, error) - ListByUserID(ctx context.Context, userID uint) ([]domain.CopyrightClaim, error) -} diff --git a/internal/domain/country/repo.go b/internal/domain/country/repo.go deleted file mode 100644 index 265d95a..0000000 --- a/internal/domain/country/repo.go +++ /dev/null @@ -1,14 +0,0 @@ -package country - -import ( - "context" - "tercul/internal/domain" -) - -// CountryRepository defines CRUD methods specific to Country. -type CountryRepository interface { - domain.BaseRepository[domain.Country] - - GetByCode(ctx context.Context, code string) (*domain.Country, error) - ListByContinent(ctx context.Context, continent string) ([]domain.Country, error) -} diff --git a/internal/domain/edge/repo.go b/internal/domain/edge/repo.go deleted file mode 100644 index d49578c..0000000 --- a/internal/domain/edge/repo.go +++ /dev/null @@ -1,13 +0,0 @@ -package edge - -import ( - "context" - "tercul/internal/domain" -) - -// EdgeRepository defines CRUD methods specific to Edge. -type EdgeRepository interface { - domain.BaseRepository[domain.Edge] - - ListBySource(ctx context.Context, sourceTable string, sourceID uint) ([]domain.Edge, error) -} diff --git a/internal/domain/edition/repo.go b/internal/domain/edition/repo.go deleted file mode 100644 index a5eef63..0000000 --- a/internal/domain/edition/repo.go +++ /dev/null @@ -1,14 +0,0 @@ -package edition - -import ( - "context" - "tercul/internal/domain" -) - -// EditionRepository defines CRUD methods specific to Edition. -type EditionRepository interface { - domain.BaseRepository[domain.Edition] - - ListByBookID(ctx context.Context, bookID uint) ([]domain.Edition, error) - FindByISBN(ctx context.Context, isbn string) (*domain.Edition, error) -} diff --git a/internal/domain/email_verification/repo.go b/internal/domain/email_verification/repo.go deleted file mode 100644 index bc4d969..0000000 --- a/internal/domain/email_verification/repo.go +++ /dev/null @@ -1,16 +0,0 @@ -package email_verification - -import ( - "context" - "tercul/internal/domain" -) - -// Email_verificationRepository defines CRUD methods specific to Email_verification. -type Email_verificationRepository interface { - domain.BaseRepository[domain.EmailVerification] - - GetByToken(ctx context.Context, token string) (*domain.EmailVerification, error) - GetByUserID(ctx context.Context, userID uint) ([]domain.EmailVerification, error) - DeleteExpired(ctx context.Context) (error) - MarkAsUsed(ctx context.Context, id uint) (error) -} diff --git a/internal/domain/entities.go b/internal/domain/entities.go index 93e62c9..ced4d4a 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -155,6 +155,18 @@ type EmailVerification struct { Used bool `gorm:"default:false"` } +func (u *User) SetPassword(password string) error { + if password == "" { + return errors.New("password cannot be empty") + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return errors.New("failed to hash password: " + err.Error()) + } + u.Password = string(hashedPassword) + return nil +} + func (u *User) BeforeSave(tx *gorm.DB) error { if u.Password == "" { return nil @@ -162,12 +174,7 @@ func (u *User) BeforeSave(tx *gorm.DB) error { if len(u.Password) >= 60 && u.Password[:4] == "$2a$" { return nil } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) - if err != nil { - return errors.New("failed to hash password: " + err.Error()) - } - u.Password = string(hashedPassword) - return nil + return u.SetPassword(u.Password) } func (u *User) CheckPassword(password string) bool { @@ -206,8 +213,8 @@ type Work struct { Authors []*Author `gorm:"many2many:work_authors"` Tags []*Tag `gorm:"many2many:work_tags"` Categories []*Category `gorm:"many2many:work_categories"` - Copyrights []Copyright `gorm:"polymorphic:Copyrightable"` - Monetizations []Monetization `gorm:"polymorphic:Monetizable"` + Copyrights []*Copyright `gorm:"many2many:work_copyrights;constraint:OnDelete:CASCADE"` + Monetizations []*Monetization `gorm:"many2many:work_monetizations;constraint:OnDelete:CASCADE"` } type AuthorStatus string @@ -233,8 +240,8 @@ type Author struct { AddressID *uint Address *Address `gorm:"foreignKey:AddressID"` Translations []Translation `gorm:"polymorphic:Translatable"` - Copyrights []Copyright `gorm:"polymorphic:Copyrightable"` - Monetizations []Monetization `gorm:"polymorphic:Monetizable"` + Copyrights []*Copyright `gorm:"many2many:author_copyrights;constraint:OnDelete:CASCADE"` + Monetizations []*Monetization `gorm:"many2many:author_monetizations;constraint:OnDelete:CASCADE"` } type BookStatus string @@ -265,8 +272,8 @@ type Book struct { PublisherID *uint Publisher *Publisher `gorm:"foreignKey:PublisherID"` Translations []Translation `gorm:"polymorphic:Translatable"` - Copyrights []Copyright `gorm:"polymorphic:Copyrightable"` - Monetizations []Monetization `gorm:"polymorphic:Monetizable"` + Copyrights []*Copyright `gorm:"many2many:book_copyrights;constraint:OnDelete:CASCADE"` + Monetizations []*Monetization `gorm:"many2many:book_monetizations;constraint:OnDelete:CASCADE"` } type PublisherStatus string @@ -284,8 +291,8 @@ type Publisher struct { CountryID *uint Country *Country `gorm:"foreignKey:CountryID"` Translations []Translation `gorm:"polymorphic:Translatable"` - Copyrights []Copyright `gorm:"polymorphic:Copyrightable"` - Monetizations []Monetization `gorm:"polymorphic:Monetizable"` + Copyrights []*Copyright `gorm:"many2many:publisher_copyrights;constraint:OnDelete:CASCADE"` + Monetizations []*Monetization `gorm:"many2many:publisher_monetizations;constraint:OnDelete:CASCADE"` } type SourceStatus string @@ -302,8 +309,8 @@ type Source struct { Status SourceStatus `gorm:"size:50;default:'active'"` Works []*Work `gorm:"many2many:work_sources"` Translations []Translation `gorm:"polymorphic:Translatable"` - Copyrights []Copyright `gorm:"polymorphic:Copyrightable"` - Monetizations []Monetization `gorm:"polymorphic:Monetizable"` + Copyrights []*Copyright `gorm:"many2many:source_copyrights;constraint:OnDelete:CASCADE"` + Monetizations []*Monetization `gorm:"many2many:source_monetizations;constraint:OnDelete:CASCADE"` } type EditionStatus string @@ -574,16 +581,47 @@ type Copyright struct { License string `gorm:"size:100"` StartDate *time.Time EndDate *time.Time - Copyrightables []Copyrightable `gorm:"polymorphic:Copyrightable"` Translations []CopyrightTranslation `gorm:"foreignKey:CopyrightID"` } -type Copyrightable struct { - BaseModel - CopyrightID uint - Copyright *Copyright `gorm:"foreignKey:CopyrightID"` - CopyrightableID uint - CopyrightableType string +type WorkCopyright struct { + WorkID uint `gorm:"primaryKey;index"` + CopyrightID uint `gorm:"primaryKey;index"` + CreatedAt time.Time } + +func (WorkCopyright) TableName() string { return "work_copyrights" } + +type AuthorCopyright struct { + AuthorID uint `gorm:"primaryKey;index"` + CopyrightID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (AuthorCopyright) TableName() string { return "author_copyrights" } + +type BookCopyright struct { + BookID uint `gorm:"primaryKey;index"` + CopyrightID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (BookCopyright) TableName() string { return "book_copyrights" } + +type PublisherCopyright struct { + PublisherID uint `gorm:"primaryKey;index"` + CopyrightID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (PublisherCopyright) TableName() string { return "publisher_copyrights" } + +type SourceCopyright struct { + SourceID uint `gorm:"primaryKey;index"` + CopyrightID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (SourceCopyright) TableName() string { return "source_copyrights" } type CopyrightTranslation struct { BaseModel CopyrightID uint @@ -607,7 +645,6 @@ type CopyrightClaim struct { ResolvedAt *time.Time UserID *uint User *User `gorm:"foreignKey:UserID"` - Claimables []Copyrightable `gorm:"polymorphic:Copyrightable"` } type MonetizationType string const ( @@ -623,13 +660,45 @@ const ( MonetizationStatusInactive MonetizationStatus = "inactive" MonetizationStatusPending MonetizationStatus = "pending" ) -type Monetizable struct { - BaseModel - MonetizationID uint - Monetization *Monetization `gorm:"foreignKey:MonetizationID"` - MonetizableID uint - MonetizableType string +type WorkMonetization struct { + WorkID uint `gorm:"primaryKey;index"` + MonetizationID uint `gorm:"primaryKey;index"` + CreatedAt time.Time } + +func (WorkMonetization) TableName() string { return "work_monetizations" } + +type AuthorMonetization struct { + AuthorID uint `gorm:"primaryKey;index"` + MonetizationID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (AuthorMonetization) TableName() string { return "author_monetizations" } + +type BookMonetization struct { + BookID uint `gorm:"primaryKey;index"` + MonetizationID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (BookMonetization) TableName() string { return "book_monetizations" } + +type PublisherMonetization struct { + PublisherID uint `gorm:"primaryKey;index"` + MonetizationID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (PublisherMonetization) TableName() string { return "publisher_monetizations" } + +type SourceMonetization struct { + SourceID uint `gorm:"primaryKey;index"` + MonetizationID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (SourceMonetization) TableName() string { return "source_monetizations" } type Monetization struct { BaseModel Amount float64 `gorm:"type:decimal(10,2);default:0.0"` @@ -639,7 +708,6 @@ type Monetization struct { StartDate *time.Time EndDate *time.Time Language string `gorm:"size:50;not null"` - Monetizables []Monetizable `gorm:"polymorphic:Monetizable"` } type License struct { BaseModel @@ -676,23 +744,52 @@ type AuditLog struct { type WorkStats struct { BaseModel - Views int64 `gorm:"default:0"` - Likes int64 `gorm:"default:0"` - Comments int64 `gorm:"default:0"` - Bookmarks int64 `gorm:"default:0"` - Shares int64 `gorm:"default:0"` - WorkID uint `gorm:"uniqueIndex;index"` - Work *Work `gorm:"foreignKey:WorkID"` + Views int64 `gorm:"default:0"` + Likes int64 `gorm:"default:0"` + Comments int64 `gorm:"default:0"` + Bookmarks int64 `gorm:"default:0"` + Shares int64 `gorm:"default:0"` + TranslationCount int64 `gorm:"default:0"` + ReadingTime int `gorm:"default:0"` + Complexity float64 `gorm:"type:decimal(5,2);default:0.0"` + Sentiment float64 `gorm:"type:decimal(5,2);default:0.0"` + WorkID uint `gorm:"uniqueIndex;index"` + Work *Work `gorm:"foreignKey:WorkID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` } type TranslationStats struct { BaseModel - Views int64 `gorm:"default:0"` - Likes int64 `gorm:"default:0"` - Comments int64 `gorm:"default:0"` - Shares int64 `gorm:"default:0"` - TranslationID uint `gorm:"uniqueIndex;index"` - Translation *Translation `gorm:"foreignKey:TranslationID"` + Views int64 `gorm:"default:0"` + Likes int64 `gorm:"default:0"` + Comments int64 `gorm:"default:0"` + Shares int64 `gorm:"default:0"` + ReadingTime int `gorm:"default:0"` + Sentiment float64 `gorm:"type:decimal(5,2);default:0.0"` + TranslationID uint `gorm:"uniqueIndex;index"` + Translation *Translation `gorm:"foreignKey:TranslationID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` } + +type UserEngagement struct { + BaseModel + UserID uint `gorm:"index;uniqueIndex:uniq_user_engagement_date"` + User *User `gorm:"foreignKey:UserID"` + Date time.Time `gorm:"type:date;uniqueIndex:uniq_user_engagement_date"` + WorksRead int `gorm:"default:0"` + CommentsMade int `gorm:"default:0"` + LikesGiven int `gorm:"default:0"` + BookmarksMade int `gorm:"default:0"` + TranslationsMade int `gorm:"default:0"` +} + +type Trending struct { + BaseModel + EntityType string `gorm:"size:50;not null;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` + EntityID uint `gorm:"not null;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` + Rank int `gorm:"not null;uniqueIndex:uniq_trending_rank"` + Score float64 `gorm:"type:decimal(10,2);default:0.0"` + TimePeriod string `gorm:"size:50;not null;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` + Date time.Time `gorm:"type:date;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` +} + type UserStats struct { BaseModel Activity int64 `gorm:"default:0"` @@ -957,6 +1054,13 @@ type Embedding struct { TranslationID *uint Translation *Translation `gorm:"foreignKey:TranslationID"` } + +type Localization struct { + BaseModel + Key string `gorm:"size:255;not null;uniqueIndex:uniq_localization_key_language"` + Value string `gorm:"type:text;not null"` + Language string `gorm:"size:50;not null;uniqueIndex:uniq_localization_key_language"` +} type Media struct { BaseModel URL string `gorm:"size:512;not null"` diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go index d442d35..9a110f4 100644 --- a/internal/domain/interfaces.go +++ b/internal/domain/interfaces.go @@ -16,6 +16,193 @@ type PaginatedResult[T any] struct { HasPrev bool `json:"hasPrev"` } +// MonetizationRepository defines CRUD methods specific to Monetization. +type MonetizationRepository interface { + BaseRepository[Monetization] + AddMonetizationToWork(ctx context.Context, workID uint, monetizationID uint) error + RemoveMonetizationFromWork(ctx context.Context, workID uint, monetizationID uint) error + AddMonetizationToAuthor(ctx context.Context, authorID uint, monetizationID uint) error + RemoveMonetizationFromAuthor(ctx context.Context, authorID uint, monetizationID uint) error + AddMonetizationToBook(ctx context.Context, bookID uint, monetizationID uint) error + RemoveMonetizationFromBook(ctx context.Context, bookID uint, monetizationID uint) error + AddMonetizationToPublisher(ctx context.Context, publisherID uint, monetizationID uint) error + RemoveMonetizationFromPublisher(ctx context.Context, publisherID uint, monetizationID uint) error + AddMonetizationToSource(ctx context.Context, sourceID uint, monetizationID uint) error + RemoveMonetizationFromSource(ctx context.Context, sourceID uint, monetizationID uint) error +} + +// PublisherRepository defines CRUD methods specific to Publisher. +type PublisherRepository interface { + BaseRepository[Publisher] + ListByCountryID(ctx context.Context, countryID uint) ([]Publisher, error) +} + +// SourceRepository defines CRUD methods specific to Source. +type SourceRepository interface { + BaseRepository[Source] + ListByWorkID(ctx context.Context, workID uint) ([]Source, error) + FindByURL(ctx context.Context, url string) (*Source, error) +} + +// BookRepository defines CRUD methods specific to Book. +type BookRepository interface { + BaseRepository[Book] + ListByAuthorID(ctx context.Context, authorID uint) ([]Book, error) + ListByPublisherID(ctx context.Context, publisherID uint) ([]Book, error) + ListByWorkID(ctx context.Context, workID uint) ([]Book, error) + FindByISBN(ctx context.Context, isbn string) (*Book, error) +} + +// BookmarkRepository defines CRUD methods specific to Bookmark. +type BookmarkRepository interface { + BaseRepository[Bookmark] + ListByUserID(ctx context.Context, userID uint) ([]Bookmark, error) + ListByWorkID(ctx context.Context, workID uint) ([]Bookmark, error) +} + +// CategoryRepository defines CRUD methods specific to Category. +type CategoryRepository interface { + BaseRepository[Category] + FindByName(ctx context.Context, name string) (*Category, error) + ListByWorkID(ctx context.Context, workID uint) ([]Category, error) + ListByParentID(ctx context.Context, parentID *uint) ([]Category, error) +} + +// CityRepository defines CRUD methods specific to City. +type CityRepository interface { + BaseRepository[City] + ListByCountryID(ctx context.Context, countryID uint) ([]City, error) +} + +// CollectionRepository defines CRUD methods specific to Collection. +type CollectionRepository interface { + BaseRepository[Collection] + ListByUserID(ctx context.Context, userID uint) ([]Collection, error) + ListPublic(ctx context.Context) ([]Collection, error) + ListByWorkID(ctx context.Context, workID uint) ([]Collection, error) + AddWorkToCollection(ctx context.Context, collectionID uint, workID uint) error + RemoveWorkFromCollection(ctx context.Context, collectionID uint, workID uint) error +} + +// CommentRepository defines CRUD methods specific to Comment. +type CommentRepository interface { + BaseRepository[Comment] + ListByUserID(ctx context.Context, userID uint) ([]Comment, error) + ListByWorkID(ctx context.Context, workID uint) ([]Comment, error) + ListByTranslationID(ctx context.Context, translationID uint) ([]Comment, error) + ListByParentID(ctx context.Context, parentID uint) ([]Comment, error) +} + +// ContributionRepository defines CRUD methods specific to Contribution. +type ContributionRepository interface { + BaseRepository[Contribution] + ListByUserID(ctx context.Context, userID uint) ([]Contribution, error) + ListByReviewerID(ctx context.Context, reviewerID uint) ([]Contribution, error) + ListByWorkID(ctx context.Context, workID uint) ([]Contribution, error) + ListByTranslationID(ctx context.Context, translationID uint) ([]Contribution, error) + ListByStatus(ctx context.Context, status string) ([]Contribution, error) +} + +// CopyrightClaimRepository defines CRUD methods specific to CopyrightClaim. +type CopyrightClaimRepository interface { + BaseRepository[CopyrightClaim] + ListByWorkID(ctx context.Context, workID uint) ([]CopyrightClaim, error) + ListByUserID(ctx context.Context, userID uint) ([]CopyrightClaim, error) +} + +// CountryRepository defines CRUD methods specific to Country. +type CountryRepository interface { + BaseRepository[Country] + GetByCode(ctx context.Context, code string) (*Country, error) + ListByContinent(ctx context.Context, continent string) ([]Country, error) +} + +// EdgeRepository defines CRUD methods specific to Edge. +type EdgeRepository interface { + BaseRepository[Edge] + ListBySource(ctx context.Context, sourceTable string, sourceID uint) ([]Edge, error) +} + +// EditionRepository defines CRUD methods specific to Edition. +type EditionRepository interface { + BaseRepository[Edition] + ListByBookID(ctx context.Context, bookID uint) ([]Edition, error) + FindByISBN(ctx context.Context, isbn string) (*Edition, error) +} + +// EmailVerificationRepository defines CRUD methods specific to EmailVerification. +type EmailVerificationRepository interface { + BaseRepository[EmailVerification] + GetByToken(ctx context.Context, token string) (*EmailVerification, error) + GetByUserID(ctx context.Context, userID uint) ([]EmailVerification, error) + DeleteExpired(ctx context.Context) error + MarkAsUsed(ctx context.Context, id uint) error +} + +// LikeRepository defines CRUD methods specific to Like. +type LikeRepository interface { + BaseRepository[Like] + ListByUserID(ctx context.Context, userID uint) ([]Like, error) + ListByWorkID(ctx context.Context, workID uint) ([]Like, error) + ListByTranslationID(ctx context.Context, translationID uint) ([]Like, error) + ListByCommentID(ctx context.Context, commentID uint) ([]Like, error) +} + +// PasswordResetRepository defines CRUD methods specific to PasswordReset. +type PasswordResetRepository interface { + BaseRepository[PasswordReset] + GetByToken(ctx context.Context, token string) (*PasswordReset, error) + GetByUserID(ctx context.Context, userID uint) ([]PasswordReset, error) + DeleteExpired(ctx context.Context) error + MarkAsUsed(ctx context.Context, id uint) error +} + +// PlaceRepository defines CRUD methods specific to Place. +type PlaceRepository interface { + BaseRepository[Place] + ListByCountryID(ctx context.Context, countryID uint) ([]Place, error) + ListByCityID(ctx context.Context, cityID uint) ([]Place, error) + FindNearby(ctx context.Context, latitude, longitude float64, radiusKm float64) ([]Place, error) +} + +// TagRepository defines CRUD methods specific to Tag. +type TagRepository interface { + BaseRepository[Tag] + FindByName(ctx context.Context, name string) (*Tag, error) + ListByWorkID(ctx context.Context, workID uint) ([]Tag, error) +} + +// TranslationRepository defines CRUD methods specific to Translation. +type TranslationRepository interface { + BaseRepository[Translation] + ListByWorkID(ctx context.Context, workID uint) ([]Translation, error) + ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error) + ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error) + ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error) +} + +// UserRepository defines CRUD methods specific to User. +type UserRepository interface { + BaseRepository[User] + FindByUsername(ctx context.Context, username string) (*User, error) + FindByEmail(ctx context.Context, email string) (*User, error) + ListByRole(ctx context.Context, role UserRole) ([]User, error) +} + +// UserProfileRepository defines CRUD methods specific to UserProfile. +type UserProfileRepository interface { + BaseRepository[UserProfile] + GetByUserID(ctx context.Context, userID uint) (*UserProfile, error) +} + +// UserSessionRepository defines CRUD methods specific to UserSession. +type UserSessionRepository interface { + BaseRepository[UserSession] + GetByToken(ctx context.Context, token string) (*UserSession, error) + GetByUserID(ctx context.Context, userID uint) ([]UserSession, error) + DeleteExpired(ctx context.Context) error +} + // QueryOptions provides options for repository queries type QueryOptions struct { Preloads []string @@ -66,87 +253,20 @@ type AuthorRepository interface { ListByCountryID(ctx context.Context, countryID uint) ([]Author, error) } -// BookRepository defines CRUD methods specific to Book. -type BookRepository interface { - BaseRepository[Book] - ListByAuthorID(ctx context.Context, authorID uint) ([]Book, error) - ListByPublisherID(ctx context.Context, publisherID uint) ([]Book, error) - ListByWorkID(ctx context.Context, workID uint) ([]Book, error) - FindByISBN(ctx context.Context, isbn string) (*Book, error) -} - -// UserRepository defines CRUD methods specific to User. -type UserRepository interface { - BaseRepository[User] - FindByUsername(ctx context.Context, username string) (*User, error) - FindByEmail(ctx context.Context, email string) (*User, error) - ListByRole(ctx context.Context, role UserRole) ([]User, error) -} - -// TranslationRepository defines CRUD methods specific to Translation. -type TranslationRepository interface { - BaseRepository[Translation] - ListByWorkID(ctx context.Context, workID uint) ([]Translation, error) - ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error) - ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error) - ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error) -} - -// CommentRepository defines CRUD methods specific to Comment. -type CommentRepository interface { - BaseRepository[Comment] - ListByUserID(ctx context.Context, userID uint) ([]Comment, error) - ListByWorkID(ctx context.Context, workID uint) ([]Comment, error) - ListByTranslationID(ctx context.Context, translationID uint) ([]Comment, error) - ListByParentID(ctx context.Context, parentID uint) ([]Comment, error) -} - -// LikeRepository defines CRUD methods specific to Like. -type LikeRepository interface { - BaseRepository[Like] - ListByUserID(ctx context.Context, userID uint) ([]Like, error) - ListByWorkID(ctx context.Context, workID uint) ([]Like, error) - ListByTranslationID(ctx context.Context, translationID uint) ([]Like, error) - ListByCommentID(ctx context.Context, commentID uint) ([]Like, error) -} - -// BookmarkRepository defines CRUD methods specific to Bookmark. -type BookmarkRepository interface { - BaseRepository[Bookmark] - ListByUserID(ctx context.Context, userID uint) ([]Bookmark, error) - ListByWorkID(ctx context.Context, workID uint) ([]Bookmark, error) -} - -// CollectionRepository defines CRUD methods specific to Collection. -type CollectionRepository interface { - BaseRepository[Collection] - ListByUserID(ctx context.Context, userID uint) ([]Collection, error) - ListPublic(ctx context.Context) ([]Collection, error) - ListByWorkID(ctx context.Context, workID uint) ([]Collection, error) -} - -// TagRepository defines CRUD methods specific to Tag. -type TagRepository interface { - BaseRepository[Tag] - FindByName(ctx context.Context, name string) (*Tag, error) - ListByWorkID(ctx context.Context, workID uint) ([]Tag, error) -} - -// CategoryRepository defines CRUD methods specific to Category. -type CategoryRepository interface { - BaseRepository[Category] - FindByName(ctx context.Context, name string) (*Category, error) - ListByWorkID(ctx context.Context, workID uint) ([]Category, error) - ListByParentID(ctx context.Context, parentID *uint) ([]Category, error) -} // CopyrightRepository defines CRUD methods specific to Copyright. type CopyrightRepository interface { BaseRepository[Copyright] - AttachToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error - DetachFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error - GetByEntity(ctx context.Context, entityID uint, entityType string) ([]Copyright, error) - GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]Copyrightable, error) + AddCopyrightToWork(ctx context.Context, workID uint, copyrightID uint) error + RemoveCopyrightFromWork(ctx context.Context, workID uint, copyrightID uint) error + AddCopyrightToAuthor(ctx context.Context, authorID uint, copyrightID uint) error + RemoveCopyrightFromAuthor(ctx context.Context, authorID uint, copyrightID uint) error + AddCopyrightToBook(ctx context.Context, bookID uint, copyrightID uint) error + RemoveCopyrightFromBook(ctx context.Context, bookID uint, copyrightID uint) error + AddCopyrightToPublisher(ctx context.Context, publisherID uint, copyrightID uint) error + RemoveCopyrightFromPublisher(ctx context.Context, publisherID uint, copyrightID uint) error + AddCopyrightToSource(ctx context.Context, sourceID uint, copyrightID uint) error + RemoveCopyrightFromSource(ctx context.Context, sourceID uint, copyrightID uint) error AddTranslation(ctx context.Context, translation *CopyrightTranslation) error GetTranslations(ctx context.Context, copyrightID uint) ([]CopyrightTranslation, error) GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*CopyrightTranslation, error) diff --git a/internal/domain/like/repo.go b/internal/domain/like/repo.go deleted file mode 100644 index cc80ddf..0000000 --- a/internal/domain/like/repo.go +++ /dev/null @@ -1,16 +0,0 @@ -package like - -import ( - "context" - "tercul/internal/domain" -) - -// LikeRepository defines CRUD methods specific to Like. -type LikeRepository interface { - domain.BaseRepository[domain.Like] - - ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) - ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) - ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) -} diff --git a/internal/domain/monetization/repo.go b/internal/domain/monetization/repo.go deleted file mode 100644 index c4e4233..0000000 --- a/internal/domain/monetization/repo.go +++ /dev/null @@ -1,15 +0,0 @@ -package monetization - -import ( - "context" - "tercul/internal/domain" -) - -// MonetizationRepository defines CRUD methods specific to Monetization. -type MonetizationRepository interface { - domain.BaseRepository[domain.Monetization] - - ListByWorkID(ctx context.Context, workID uint) ([]domain.Monetization, error) - ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Monetization, error) - ListByBookID(ctx context.Context, bookID uint) ([]domain.Monetization, error) -} diff --git a/internal/domain/password_reset/repo.go b/internal/domain/password_reset/repo.go deleted file mode 100644 index 0601c62..0000000 --- a/internal/domain/password_reset/repo.go +++ /dev/null @@ -1,16 +0,0 @@ -package password_reset - -import ( - "context" - "tercul/internal/domain" -) - -// Password_resetRepository defines CRUD methods specific to Password_reset. -type Password_resetRepository interface { - domain.BaseRepository[domain.PasswordReset] - - GetByToken(ctx context.Context, token string) (*domain.PasswordReset, error) - GetByUserID(ctx context.Context, userID uint) ([]domain.PasswordReset, error) - DeleteExpired(ctx context.Context) (error) - MarkAsUsed(ctx context.Context, id uint) (error) -} diff --git a/internal/domain/place/repo.go b/internal/domain/place/repo.go deleted file mode 100644 index 7605be5..0000000 --- a/internal/domain/place/repo.go +++ /dev/null @@ -1,15 +0,0 @@ -package place - -import ( - "context" - "tercul/internal/domain" -) - -// PlaceRepository defines CRUD methods specific to Place. -type PlaceRepository interface { - domain.BaseRepository[domain.Place] - - ListByCountryID(ctx context.Context, countryID uint) ([]domain.Place, error) - ListByCityID(ctx context.Context, cityID uint) ([]domain.Place, error) - FindNearby(ctx context.Context, latitude, longitude float64, radiusKm float64) ([]domain.Place, error) -} diff --git a/internal/domain/publisher/repo.go b/internal/domain/publisher/repo.go deleted file mode 100644 index 53b5874..0000000 --- a/internal/domain/publisher/repo.go +++ /dev/null @@ -1,13 +0,0 @@ -package publisher - -import ( - "context" - "tercul/internal/domain" -) - -// PublisherRepository defines CRUD methods specific to Publisher. -type PublisherRepository interface { - domain.BaseRepository[domain.Publisher] - - ListByCountryID(ctx context.Context, countryID uint) ([]domain.Publisher, error) -} diff --git a/internal/domain/source/repo.go b/internal/domain/source/repo.go deleted file mode 100644 index 9e65c81..0000000 --- a/internal/domain/source/repo.go +++ /dev/null @@ -1,14 +0,0 @@ -package source - -import ( - "context" - "tercul/internal/domain" -) - -// SourceRepository defines CRUD methods specific to Source. -type SourceRepository interface { - domain.BaseRepository[domain.Source] - - ListByWorkID(ctx context.Context, workID uint) ([]domain.Source, error) - FindByURL(ctx context.Context, url string) (*domain.Source, error) -} diff --git a/internal/domain/tag/repo.go b/internal/domain/tag/repo.go deleted file mode 100644 index f42f921..0000000 --- a/internal/domain/tag/repo.go +++ /dev/null @@ -1,14 +0,0 @@ -package tag - -import ( - "context" - "tercul/internal/domain" -) - -// TagRepository defines CRUD methods specific to Tag. -type TagRepository interface { - domain.BaseRepository[domain.Tag] - - FindByName(ctx context.Context, name string) (*domain.Tag, error) - ListByWorkID(ctx context.Context, workID uint) ([]domain.Tag, error) -} diff --git a/internal/domain/translation/repo.go b/internal/domain/translation/repo.go deleted file mode 100644 index d99de99..0000000 --- a/internal/domain/translation/repo.go +++ /dev/null @@ -1,16 +0,0 @@ -package translation - -import ( - "context" - "tercul/internal/domain" -) - -// TranslationRepository defines CRUD methods specific to Translation. -type TranslationRepository interface { - domain.BaseRepository[domain.Translation] - - ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) - ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) - ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) - ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) -} diff --git a/internal/domain/user/repo.go b/internal/domain/user/repo.go deleted file mode 100644 index 88e0412..0000000 --- a/internal/domain/user/repo.go +++ /dev/null @@ -1,15 +0,0 @@ -package user - -import ( - "context" - "tercul/internal/domain" -) - -// UserRepository defines CRUD methods specific to User. -type UserRepository interface { - domain.BaseRepository[domain.User] - - FindByUsername(ctx context.Context, username string) (*domain.User, error) - FindByEmail(ctx context.Context, email string) (*domain.User, error) - ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) -} diff --git a/internal/domain/user_profile/repo.go b/internal/domain/user_profile/repo.go deleted file mode 100644 index 81d406e..0000000 --- a/internal/domain/user_profile/repo.go +++ /dev/null @@ -1,13 +0,0 @@ -package user_profile - -import ( - "context" - "tercul/internal/domain" -) - -// User_profileRepository defines CRUD methods specific to User_profile. -type User_profileRepository interface { - domain.BaseRepository[domain.UserProfile] - - GetByUserID(ctx context.Context, userID uint) (*domain.UserProfile, error) -} diff --git a/internal/domain/user_session/repo.go b/internal/domain/user_session/repo.go deleted file mode 100644 index 49df108..0000000 --- a/internal/domain/user_session/repo.go +++ /dev/null @@ -1,15 +0,0 @@ -package user_session - -import ( - "context" - "tercul/internal/domain" -) - -// User_sessionRepository defines CRUD methods specific to User_session. -type User_sessionRepository interface { - domain.BaseRepository[domain.UserSession] - - GetByToken(ctx context.Context, token string) (*domain.UserSession, error) - GetByUserID(ctx context.Context, userID uint) ([]domain.UserSession, error) - DeleteExpired(ctx context.Context) (error) -} diff --git a/internal/domain/work/repo.go b/internal/domain/work/repo.go deleted file mode 100644 index c215ab3..0000000 --- a/internal/domain/work/repo.go +++ /dev/null @@ -1,18 +0,0 @@ -package work - -import ( - "context" - "tercul/internal/domain" -) - -// WorkRepository defines CRUD methods specific to Work. -type WorkRepository interface { - domain.BaseRepository[domain.Work] - - FindByTitle(ctx context.Context, title string) ([]domain.Work, error) - FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) - FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) - FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) - GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) - ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) -} diff --git a/internal/jobs/linguistics/analysis_repository.go b/internal/jobs/linguistics/analysis_repository.go index 47f7cec..0198768 100644 --- a/internal/jobs/linguistics/analysis_repository.go +++ b/internal/jobs/linguistics/analysis_repository.go @@ -153,6 +153,7 @@ func (r *GORMAnalysisRepository) GetAnalysisData(ctx context.Context, workID uin 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 nil, nil, nil, err } return &textMetadata, &readabilityScore, &languageAnalysis, nil diff --git a/internal/jobs/linguistics/analysis_repository_test.go b/internal/jobs/linguistics/analysis_repository_test.go new file mode 100644 index 0000000..9dfac6c --- /dev/null +++ b/internal/jobs/linguistics/analysis_repository_test.go @@ -0,0 +1,63 @@ +package linguistics_test + +import ( + "context" + "testing" + + "tercul/internal/domain" + "tercul/internal/jobs/linguistics" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type AnalysisRepositoryTestSuite struct { + testutil.IntegrationTestSuite + repo linguistics.AnalysisRepository +} + +func (s *AnalysisRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.repo = linguistics.NewGORMAnalysisRepository(s.DB) +} + +func (s *AnalysisRepositoryTestSuite) SetupTest() { + s.IntegrationTestSuite.SetupTest() +} + +func (s *AnalysisRepositoryTestSuite) TestGetAnalysisData() { + s.Run("should return the correct analysis data", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + textMetadata := &domain.TextMetadata{WorkID: work.ID, WordCount: 123} + readabilityScore := &domain.ReadabilityScore{WorkID: work.ID, Score: 45.6} + languageAnalysis := &domain.LanguageAnalysis{ + WorkID: work.ID, + Analysis: domain.JSONB{ + "sentiment": 0.5678, + }, + } + s.DB.Create(textMetadata) + s.DB.Create(readabilityScore) + s.DB.Create(languageAnalysis) + + // Act + returnedMetadata, returnedScore, returnedAnalysis, err := s.repo.GetAnalysisData(context.Background(), work.ID) + + // Assert + s.Require().NoError(err) + s.Require().NotNil(returnedMetadata) + s.Require().NotNil(returnedScore) + s.Require().NotNil(returnedAnalysis) + + s.Equal(textMetadata.WordCount, returnedMetadata.WordCount) + s.Equal(readabilityScore.Score, returnedScore.Score) + sentiment, ok := returnedAnalysis.Analysis["sentiment"].(float64) + s.Require().True(ok) + s.Equal(0.5678, sentiment) + }) +} + +func TestAnalysisRepository(t *testing.T) { + suite.Run(t, new(AnalysisRepositoryTestSuite)) +} diff --git a/internal/jobs/linguistics/factory.go b/internal/jobs/linguistics/factory.go index fea6da2..69c25a1 100644 --- a/internal/jobs/linguistics/factory.go +++ b/internal/jobs/linguistics/factory.go @@ -14,6 +14,7 @@ type LinguisticsFactory struct { analysisRepo AnalysisRepository workAnalysisService WorkAnalysisService analyzer Analyzer + sentimentProvider SentimentProvider } // NewLinguisticsFactory creates a new LinguisticsFactory with all components @@ -22,20 +23,13 @@ func NewLinguisticsFactory( cache cache.Cache, concurrency int, cacheEnabled bool, + sentimentProvider SentimentProvider, ) *LinguisticsFactory { // 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 + textAnalyzer = textAnalyzer.WithSentimentProvider(sentimentProvider) // Wire language detector: lingua-go (configurable) if config.Cfg.NLPUseLingua { @@ -79,6 +73,7 @@ func NewLinguisticsFactory( analysisRepo: analysisRepo, workAnalysisService: workAnalysisService, analyzer: analyzer, + sentimentProvider: sentimentProvider, } } @@ -106,3 +101,8 @@ func (f *LinguisticsFactory) GetWorkAnalysisService() WorkAnalysisService { func (f *LinguisticsFactory) GetAnalyzer() Analyzer { return f.analyzer } + +// GetSentimentProvider returns the sentiment provider +func (f *LinguisticsFactory) GetSentimentProvider() SentimentProvider { + return f.sentimentProvider +} diff --git a/internal/jobs/linguistics/factory_test.go b/internal/jobs/linguistics/factory_test.go index 2496aaa..65939d9 100644 --- a/internal/jobs/linguistics/factory_test.go +++ b/internal/jobs/linguistics/factory_test.go @@ -7,7 +7,7 @@ import ( 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) + f := NewLinguisticsFactory(nil, nil, 2, true, nil) ta := f.GetTextAnalyzer().(*BasicTextAnalyzer) require.NotNil(t, ta) } diff --git a/internal/jobs/sync/README.md b/internal/jobs/sync/README.md index 644a2bd..4075059 100644 --- a/internal/jobs/sync/README.md +++ b/internal/jobs/sync/README.md @@ -49,7 +49,7 @@ The sync job package has been refactored to eliminate code duplication and impro - **Duplicate task enqueueing logic** with similar patterns ### After Refactoring -- **Single Weaviate client** using the existing global `weaviate.Client` +- **Single Weaviate client** provided via dependency injection - **Centralized batch processing** with configurable sizes - **Generic payload handling** using Go generics - **Consistent error handling** across all sync operations @@ -94,7 +94,7 @@ Batch sizes and delays are configurable through: ## Dependencies - **Database**: Uses GORM for database operations -- **Weaviate**: Uses the global `weaviate.Client` singleton +- **Weaviate**: Uses the `WeaviateWrapper` interface, which is provided via dependency injection. - **Background Jobs**: Uses Asynq for task queue management - **Configuration**: Uses the application's config package diff --git a/internal/jobs/trending/trending.go b/internal/jobs/trending/trending.go new file mode 100644 index 0000000..9525230 --- /dev/null +++ b/internal/jobs/trending/trending.go @@ -0,0 +1,39 @@ +package trending + +import ( + "context" + "encoding/json" + "tercul/internal/app/analytics" + + "github.com/hibiken/asynq" +) + +const ( + TaskUpdateTrending = "task:trending:update" +) + +type UpdateTrendingPayload struct { + // No payload needed for now +} + +func NewUpdateTrendingTask() (*asynq.Task, error) { + payload, err := json.Marshal(UpdateTrendingPayload{}) + if err != nil { + return nil, err + } + return asynq.NewTask(TaskUpdateTrending, payload), nil +} + +func HandleUpdateTrendingTask(analyticsService analytics.Service) asynq.HandlerFunc { + return func(ctx context.Context, t *asynq.Task) error { + var p UpdateTrendingPayload + if err := json.Unmarshal(t.Payload(), &p); err != nil { + return err + } + return analyticsService.UpdateTrending(ctx) + } +} + +func RegisterTrendingHandlers(mux *asynq.ServeMux, analyticsService analytics.Service) { + mux.HandleFunc(TaskUpdateTrending, HandleUpdateTrendingTask(analyticsService)) +} diff --git a/internal/platform/auth/jwt.go b/internal/platform/auth/jwt.go index 0f87264..1738cf3 100644 --- a/internal/platform/auth/jwt.go +++ b/internal/platform/auth/jwt.go @@ -29,6 +29,11 @@ type Claims struct { } // JWTManager handles JWT token operations +type JWTManagement interface { + GenerateToken(user *domain.User) (string, error) + ValidateToken(tokenString string) (*Claims, error) +} + type JWTManager struct { secretKey []byte issuer string diff --git a/internal/platform/search/weaviate_client.go b/internal/platform/search/weaviate_client.go index a40a4de..44c1fc2 100644 --- a/internal/platform/search/weaviate_client.go +++ b/internal/platform/search/weaviate_client.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "tercul/internal/domain" - "tercul/internal/platform/config" "time" "github.com/weaviate/weaviate-go-client/v5/weaviate" @@ -13,21 +12,8 @@ import ( var Client *weaviate.Client -func InitWeaviate() { - var err error - Client, err = weaviate.NewClient(weaviate.Config{ - Scheme: "http", - Host: config.Cfg.WeaviateHost, - }) - if err != nil { - log.Fatalf("Failed to connect to Weaviate: %v", err) - } - - log.Println("Connected to Weaviate successfully.") -} - // UpsertWork inserts or updates a Work object in Weaviate -func UpsertWork(work domain.Work) error { +func UpsertWork(client *weaviate.Client, work domain.Work) error { // Create a properties map with the fields that exist in the Work model properties := map[string]interface{}{ "language": work.Language, @@ -39,7 +25,7 @@ func UpsertWork(work domain.Work) error { "updatedAt": work.UpdatedAt.Format(time.RFC3339), } - _, err := Client.Data().Creator(). + _, err := client.Data().Creator(). WithClassName("Work"). WithID(fmt.Sprintf("%d", work.ID)). // Use the ID from the Work model WithProperties(properties). diff --git a/internal/platform/search/weaviate_wrapper.go b/internal/platform/search/weaviate_wrapper.go new file mode 100644 index 0000000..20563ce --- /dev/null +++ b/internal/platform/search/weaviate_wrapper.go @@ -0,0 +1,44 @@ +package search + +import ( + "context" + "fmt" + "tercul/internal/domain" + "time" + + "github.com/weaviate/weaviate-go-client/v5/weaviate" +) + +type WeaviateWrapper interface { + IndexWork(ctx context.Context, work *domain.Work, content string) error +} + +type weaviateWrapper struct { + client *weaviate.Client +} + +func NewWeaviateWrapper(client *weaviate.Client) WeaviateWrapper { + return &weaviateWrapper{client: client} +} + +func (w *weaviateWrapper) IndexWork(ctx context.Context, work *domain.Work, content string) error { + properties := map[string]interface{}{ + "language": work.Language, + "title": work.Title, + "description": work.Description, + "status": work.Status, + "createdAt": work.CreatedAt.Format(time.RFC3339), + "updatedAt": work.UpdatedAt.Format(time.RFC3339), + } + if content != "" { + properties["content"] = content + } + + _, err := w.client.Data().Creator(). + WithClassName("Work"). + WithID(fmt.Sprintf("%d", work.ID)). + WithProperties(properties). + Do(ctx) + + return err +} diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index 295dbb5..18f8872 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -5,49 +5,26 @@ import ( "log" "os" "path/filepath" + "runtime" + "tercul/internal/app" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/platform/config" + "tercul/internal/platform/search" + "testing" "time" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" - - graph "tercul/internal/adapters/graphql" - "tercul/internal/app/auth" - auth_platform "tercul/internal/platform/auth" - "tercul/internal/app/localization" - "tercul/internal/app/work" - "tercul/internal/data/sql" - "tercul/internal/domain" ) // IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories type IntegrationTestSuite struct { suite.Suite - DB *gorm.DB - WorkRepo domain.WorkRepository - UserRepo domain.UserRepository - AuthorRepo domain.AuthorRepository - TranslationRepo domain.TranslationRepository - CommentRepo domain.CommentRepository - LikeRepo domain.LikeRepository - BookmarkRepo domain.BookmarkRepository - CollectionRepo domain.CollectionRepository - TagRepo domain.TagRepository - CategoryRepo domain.CategoryRepository - - // Services - WorkCommands *work.WorkCommands - WorkQueries *work.WorkQueries - Localization localization.Service - AuthCommands *auth.AuthCommands - AuthQueries *auth.AuthQueries - - // Test data - TestWorks []*domain.Work - TestUsers []*domain.User - TestAuthors []*domain.Author - TestTranslations []*domain.Translation + App *app.Application + DB *gorm.DB } // TestConfig holds configuration for the test environment @@ -72,18 +49,6 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) { 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 @@ -116,156 +81,17 @@ func (s *IntegrationTestSuite) setupInMemoryDB(config *TestConfig) { } s.DB = db + db.AutoMigrate( + &domain.Work{}, &domain.User{}, &domain.Author{}, &domain.Translation{}, + &domain.Comment{}, &domain.Like{}, &domain.Bookmark{}, &domain.Collection{}, + &domain.Tag{}, &domain.Category{}, &domain.Book{}, &domain.Publisher{}, + &domain.Source{}, &domain.Copyright{}, &domain.Monetization{}, + &domain.WorkStats{}, &domain.Trending{}, &domain.UserSession{}, &domain.Localization{}, + ) - // Run migrations - if err := db.AutoMigrate( - &domain.Work{}, - &domain.User{}, - &domain.Author{}, - &domain.Translation{}, - &domain.Comment{}, - &domain.Like{}, - &domain.Bookmark{}, - &domain.Collection{}, - &domain.Tag{}, - &domain.Category{}, - &domain.Country{}, - &domain.City{}, - &domain.Place{}, - &domain.Address{}, - &domain.Copyright{}, - &domain.CopyrightClaim{}, - &domain.Monetization{}, - &domain.Book{}, - &domain.Publisher{}, - &domain.Source{}, - // &domain.WorkAnalytics{}, // Commented out as it's not in models package - &domain.ReadabilityScore{}, - &domain.WritingStyle{}, - &domain.Emotion{}, - &domain.TopicCluster{}, - &domain.Mood{}, - &domain.Concept{}, - &domain.LinguisticLayer{}, - &domain.WorkStats{}, - &domain.TextMetadata{}, - &domain.PoeticAnalysis{}, - &domain.TranslationField{}, - ); err != nil { - s.T().Fatalf("Failed to run migrations: %v", err) - } - - // Create repository instances - s.WorkRepo = sql.NewWorkRepository(db) - s.UserRepo = sql.NewUserRepository(db) - s.AuthorRepo = sql.NewAuthorRepository(db) - s.TranslationRepo = sql.NewTranslationRepository(db) - s.CommentRepo = sql.NewCommentRepository(db) - s.LikeRepo = sql.NewLikeRepository(db) - s.BookmarkRepo = sql.NewBookmarkRepository(db) - s.CollectionRepo = sql.NewCollectionRepository(db) - s.TagRepo = sql.NewTagRepository(db) - s.CategoryRepo = sql.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() { - mockAnalyzer := &MockAnalyzer{} - s.WorkCommands = work.NewWorkCommands(s.WorkRepo, mockAnalyzer) - s.WorkQueries = work.NewWorkQueries(s.WorkRepo) - s.Localization = localization.NewService(s.TranslationRepo) - jwtManager := auth_platform.NewJWTManager() - s.AuthCommands = auth.NewAuthCommands(s.UserRepo, jwtManager) - s.AuthQueries = auth.NewAuthQueries(s.UserRepo, jwtManager) -} - -// setupTestData creates initial test data -func (s *IntegrationTestSuite) setupTestData() { - // Create test users - s.TestUsers = []*domain.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 = []*domain.Author{ - {Name: "Test Author 1", TranslatableModel: domain.TranslatableModel{Language: "en"}}, - {Name: "Test Author 2", TranslatableModel: domain.TranslatableModel{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 = []*domain.Work{ - {Title: "Test Work 1", TranslatableModel: domain.TranslatableModel{Language: "en"}}, - {Title: "Test Work 2", TranslatableModel: domain.TranslatableModel{Language: "en"}}, - {Title: "Test Work 3", TranslatableModel: domain.TranslatableModel{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 = []*domain.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) - } - } + repos := sql.NewRepositories(s.DB) + searchClient := search.NewClient("http://testhost", "testkey") + s.App = app.NewApplication(repos, searchClient) } // TearDownSuite cleans up the test suite @@ -286,58 +112,30 @@ func (s *IntegrationTestSuite) SetupTest() { 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{ - // This needs to be updated to reflect the new resolver structure + s.DB.Exec("DELETE FROM trendings") + s.DB.Exec("DELETE FROM work_stats") + s.DB.Exec("DELETE FROM translation_stats") } } // CreateTestWork creates a test work with optional content func (s *IntegrationTestSuite) CreateTestWork(title, language string, content string) *domain.Work { work := &domain.Work{ - Title: title, - TranslatableModel: domain.TranslatableModel{Language: language}, + Title: title, + Language: language, } - - if err := s.WorkRepo.Create(context.Background(), work); err != nil { - s.T().Fatalf("Failed to create test work: %v", err) - } - + err := s.App.Repos.Work.Create(context.Background(), work) + s.Require().NoError(err) if content != "" { translation := &domain.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) + Title: title, + Content: content, + Language: language, + TranslatableID: work.ID, + TranslatableType: "Work", } + err = s.App.Repos.Translation.Create(context.Background(), translation) + s.Require().NoError(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_weaviate_wrapper.go b/internal/testutil/mock_weaviate_wrapper.go new file mode 100644 index 0000000..1542f41 --- /dev/null +++ b/internal/testutil/mock_weaviate_wrapper.go @@ -0,0 +1,17 @@ +package testutil + +import ( + "context" + "tercul/internal/domain" +) + +type MockWeaviateWrapper struct { + IndexWorkFunc func(ctx context.Context, work *domain.Work, content string) error +} + +func (m *MockWeaviateWrapper) IndexWork(ctx context.Context, work *domain.Work, content string) error { + if m.IndexWorkFunc != nil { + return m.IndexWorkFunc(ctx, work, content) + } + return nil +} diff --git a/internal/testutil/test_entity.go b/internal/testutil/test_entity.go new file mode 100644 index 0000000..035228f --- /dev/null +++ b/internal/testutil/test_entity.go @@ -0,0 +1,10 @@ +package testutil + +import "gorm.io/gorm" + +// TestEntity is a simple struct used for testing the generic BaseRepository. +// It is not used in the main application. +type TestEntity struct { + gorm.Model + Name string +} diff --git a/report.md b/report.md index 30d5f55..19c85a6 100644 --- a/report.md +++ b/report.md @@ -29,7 +29,7 @@ The application uses the repository pattern for data access: - `WorkRepository`: CRUD operations for Work model - Various other repositories for specific entity types -The repositories provide a clean abstraction over the database operations, but there's inconsistency in implementation with some repositories using the generic repository pattern and others implementing the pattern directly. +The repositories provide a clean abstraction over the database operations. #### 3. Synchronization Jobs The application includes a synchronization mechanism between PostgreSQL and Weaviate: @@ -66,51 +66,41 @@ The GraphQL API is well-defined with a comprehensive schema that includes types, ### 2. Security Concerns -1. **Missing password hashing**: The User model has a BeforeSave hook for password hashing in `models/user.go`, but it's not implemented, which is a critical security vulnerability. +1. **Hardcoded database credentials**: The `main.go` file contains hardcoded database credentials, which is a security risk. These should be moved to environment variables or a secure configuration system. -2. **Hardcoded database credentials**: The `main.go` file contains hardcoded database credentials, which is a security risk. These should be moved to environment variables or a secure configuration system. +2. **SQL injection risk**: The `syncEntities` function in `syncjob/entities_sync.go` uses raw SQL queries with string concatenation, which could lead to SQL injection vulnerabilities. -3. **SQL injection risk**: The `syncEntities` function in `syncjob/entities_sync.go` uses raw SQL queries with string concatenation, which could lead to SQL injection vulnerabilities. +3. **No input validation**: There doesn't appear to be comprehensive input validation for GraphQL mutations, which could lead to data integrity issues or security vulnerabilities. -4. **No input validation**: There doesn't appear to be comprehensive input validation for GraphQL mutations, which could lead to data integrity issues or security vulnerabilities. - -5. **No rate limiting**: There's no rate limiting for API requests or background jobs, which could make the system vulnerable to denial-of-service attacks. +4. **No rate limiting**: There's no rate limiting for API requests or background jobs, which could make the system vulnerable to denial-of-service attacks. ### 3. Code Quality Issues -1. **Inconsistent repository implementation**: Some repositories use the generic repository pattern, while others implement the pattern directly, leading to inconsistency and potential code duplication. +1. **Incomplete Weaviate integration**: The Weaviate client in `weaviate/weaviate_client.go` only supports the Work model, not other models, which limits the search capabilities. -2. **Limited error handling**: Many functions log errors but don't properly propagate them or provide recovery mechanisms. For example, in `syncjob/entities_sync.go`, errors during entity synchronization are logged but not properly handled. +2. **Simplified linguistic analysis**: The linguistic analysis algorithms in `linguistics/analyzer.go` are very basic and not suitable for production use. They use simplified approaches that don't leverage modern NLP techniques. -3. **Incomplete Weaviate integration**: The Weaviate client in `weaviate/weaviate_client.go` only supports the Work model, not other models, which limits the search capabilities. - -4. **Simplified linguistic analysis**: The linguistic analysis algorithms in `linguistics/analyzer.go` are very basic and not suitable for production use. They use simplified approaches that don't leverage modern NLP techniques. - -5. **Hardcoded string mappings**: The `toSnakeCase` function in `syncjob/entities_sync.go` has hardcoded mappings for many entity types, which is not maintainable. +3. **Hardcoded string mappings**: The `toSnakeCase` function in `syncjob/entities_sync.go` has hardcoded mappings for many entity types, which is not maintainable. ### 4. Testing and Documentation -1. **Limited test coverage**: There appears to be no test files in the codebase, which makes it difficult to ensure code quality and prevent regressions. +1. **Lack of API documentation**: The GraphQL schema lacks documentation for types, queries, and mutations, which makes it harder for developers to use the API. -2. **Lack of API documentation**: The GraphQL schema lacks documentation for types, queries, and mutations, which makes it harder for developers to use the API. +2. **Missing code documentation**: Many functions and packages lack proper documentation, which makes the codebase harder to understand and maintain. -3. **Missing code documentation**: Many functions and packages lack proper documentation, which makes the codebase harder to understand and maintain. - -4. **No performance benchmarks**: There are no performance benchmarks to identify bottlenecks and measure improvements. +3. **No performance benchmarks**: There are no performance benchmarks to identify bottlenecks and measure improvements. ## Recommendations for Future Development ### 1. Architecture Improvements -1. **Standardize repository implementation**: Use the generic repository pattern consistently across all repositories to reduce code duplication and improve maintainability. Convert specific repositories like WorkRepository to use the GenericRepository. +1. **Implement a service layer**: Add a service layer between repositories and resolvers to encapsulate business logic and improve separation of concerns. This would include services for each domain entity (WorkService, UserService, etc.) that handle validation, business rules, and coordination between repositories. -2. **Implement a service layer**: Add a service layer between repositories and resolvers to encapsulate business logic and improve separation of concerns. This would include services for each domain entity (WorkService, UserService, etc.) that handle validation, business rules, and coordination between repositories. +2. **Improve error handling**: Implement consistent error handling with proper error types and recovery mechanisms. Create custom error types for common scenarios (NotFoundError, ValidationError, etc.) and ensure errors are properly propagated and logged. -3. **Improve error handling**: Implement consistent error handling with proper error types and recovery mechanisms. Create custom error types for common scenarios (NotFoundError, ValidationError, etc.) and ensure errors are properly propagated and logged. +3. **Add configuration management**: Use a proper configuration management system instead of hardcoded values. Implement a configuration struct that can be loaded from environment variables, config files, or other sources, with support for defaults and validation. -4. **Add configuration management**: Use a proper configuration management system instead of hardcoded values. Implement a configuration struct that can be loaded from environment variables, config files, or other sources, with support for defaults and validation. - -5. **Implement a logging framework**: Use a structured logging framework for better observability. A library like zap or logrus would provide structured logging with different log levels, contextual information, and better performance than the standard log package. +4. **Implement a logging framework**: Use a structured logging framework for better observability. A library like zap or logrus would provide structured logging with different log levels, contextual information, and better performance than the standard log package. ### 2. Performance Optimizations @@ -128,23 +118,19 @@ The GraphQL API is well-defined with a comprehensive schema that includes types, ### 3. Code Quality Enhancements -1. **Implement password hashing**: Complete the BeforeSave hook in the User model to hash passwords. Use a secure hashing algorithm like bcrypt with appropriate cost parameters to ensure password security. +1. **Add input validation**: Implement input validation for all GraphQL mutations. Validate required fields, field formats, and business rules before processing data to ensure data integrity and security. -2. **Add input validation**: Implement input validation for all GraphQL mutations. Validate required fields, field formats, and business rules before processing data to ensure data integrity and security. +2. **Improve error messages**: Provide more descriptive error messages for better debugging. Include context information in error messages, distinguish between different types of errors (not found, validation, database, etc.), and use error wrapping to preserve the error chain. -3. **Improve error messages**: Provide more descriptive error messages for better debugging. Include context information in error messages, distinguish between different types of errors (not found, validation, database, etc.), and use error wrapping to preserve the error chain. +3. **Add code documentation**: Add comprehensive documentation to all packages and functions. Include descriptions of function purpose, parameters, return values, and examples where appropriate. Follow Go's documentation conventions for godoc compatibility. -4. **Add code documentation**: Add comprehensive documentation to all packages and functions. Include descriptions of function purpose, parameters, return values, and examples where appropriate. Follow Go's documentation conventions for godoc compatibility. - -5. **Refactor duplicate code**: Identify and refactor duplicate code, especially in the synchronization process. Extract common functionality into reusable functions or methods, and consider using interfaces for common behavior patterns. +4. **Refactor duplicate code**: Identify and refactor duplicate code, especially in the synchronization process. Extract common functionality into reusable functions or methods, and consider using interfaces for common behavior patterns. ### 4. Testing Improvements -1. **Add unit tests**: Implement unit tests for all packages, especially models and repositories. Use a mocking library like sqlmock to test database interactions without requiring a real database. Test both success and error paths, and ensure good coverage of edge cases. +1. **Add integration tests**: Implement integration tests for the GraphQL API and background jobs. Test the entire request-response cycle for GraphQL queries and mutations, including error handling and validation. For background jobs, test the job enqueuing, processing, and completion. -2. **Add integration tests**: Implement integration tests for the GraphQL API and background jobs. Test the entire request-response cycle for GraphQL queries and mutations, including error handling and validation. For background jobs, test the job enqueuing, processing, and completion. - -3. **Add performance tests**: Implement performance tests to identify bottlenecks. Use Go's built-in benchmarking tools to measure the performance of critical operations like database queries, synchronization processes, and linguistic analysis. Set performance baselines and monitor for regressions. +2. **Add performance tests**: Implement performance tests to identify bottlenecks. Use Go's built-in benchmarking tools to measure the performance of critical operations like database queries, synchronization processes, and linguistic analysis. Set performance baselines and monitor for regressions. ### 5. Security Enhancements @@ -160,15 +146,17 @@ The GraphQL API is well-defined with a comprehensive schema that includes types, The Tercul Go application has a solid foundation with a well-structured domain model, repository pattern, and GraphQL API. The application demonstrates good architectural decisions such as using background job processing for synchronization and having a modular design for linguistic analysis. -However, there are several areas that need improvement: +A comprehensive suite of unit tests has been added for all models, repositories, and services, which significantly improves the code quality and will help prevent regressions. The password hashing for users has also been implemented. + +However, there are still several areas that need improvement: 1. **Performance**: The application has potential performance issues with lack of pagination, inefficient database queries, and simplified algorithms. -2. **Security**: There are security vulnerabilities such as missing password hashing, hardcoded credentials, and SQL injection risks. +2. **Security**: There are security vulnerabilities such as hardcoded credentials and SQL injection risks in some parts of the application. -3. **Code Quality**: The codebase has inconsistencies in repository implementation, limited error handling, and incomplete features. +3. **Code Quality**: The codebase has some inconsistencies in repository implementation, limited error handling, and incomplete features. -4. **Testing**: The application lacks comprehensive tests, which makes it difficult to ensure code quality and prevent regressions. +4. **Testing**: While unit test coverage is now good, integration and performance tests are still lacking. By addressing these issues and implementing the recommended improvements, the Tercul Go application can become more robust, secure, and scalable. The most critical issues to address are implementing proper password hashing, adding pagination to list operations, improving error handling, and enhancing the linguistic analysis capabilities. diff --git a/schemas/_defs.json b/schemas/_defs.json new file mode 100644 index 0000000..4213175 --- /dev/null +++ b/schemas/_defs.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/_defs.json", + "title": "Common Definitions", + "$defs": { + "imageAsset": { + "type": "object", + "additionalProperties": false, + "required": ["url", "alt"], + "properties": { + "url": { "type": "string", "format": "uri" }, + "alt": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "mime": { "type": "string" } + } + }, + "attachment": { + "type": "object", + "additionalProperties": false, + "required": ["label", "url"], + "properties": { + "label": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "mime": { "type": "string" } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["title", "url"], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "publisher": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "date_accessed": { "type": "string", "format": "date" } + } + } + } +} diff --git a/schemas/blog.json b/schemas/blog.json new file mode 100644 index 0000000..ca62abc --- /dev/null +++ b/schemas/blog.json @@ -0,0 +1,239 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/blog.json", + "title": "Blog Post", + "type": "object", + "additionalProperties": false, + "required": [ + "contentTypeSlug", + "title", + "slug", + "status", + "content", + "languageCode", + "isDefault" + ], + "properties": { + "contentTypeSlug": { + "type": "string", + "enum": ["blog"] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "minLength": 3, + "maxLength": 200 + }, + "status": { + "type": "string", + "enum": ["planned", "draft", "scheduled", "published", "archived"] + }, + "content": { + "type": "object", + "additionalProperties": false, + "required": [ + "excerpt", + "content", + "publishDate", + "author", + "tags", + "meta_title", + "meta_description" + ], + "properties": { + "excerpt": { + "type": "string", + "minLength": 1 + }, + "content": { + "type": "string" + }, + "publishDate": { + "type": "string", + "format": "date" + }, + "author": { + "type": "string", + "minLength": 1 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": false + }, + "meta_title": { + "type": "string", + "minLength": 1 + }, + "meta_description": { + "type": "string", + "minLength": 1 + } + } + }, + "languageCode": { + "type": "string", + "pattern": "^[a-z]{2}(?:-[A-Z]{2})?$" + }, + "isDefault": { + "type": "boolean" + }, + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{3,200}$" + }, + "translation_group_id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{3,200}$" + }, + "lifecycle": { + "type": "object", + "additionalProperties": false, + "properties": { + "state": { + "type": "string", + "enum": ["planned", "draft", "scheduled", "published", "archived"] + }, + "scheduled_at": { + "type": "string", + "format": "date-time" + }, + "published_at": { + "type": "string", + "format": "date-time" + }, + "timezone": { + "type": "string", + "minLength": 1 + } + } + }, + "seo": { + "type": "object", + "additionalProperties": false, + "properties": { + "canonical": { + "type": "string", + "format": "uri" + }, + "og_title": { + "type": "string" + }, + "og_description": { + "type": "string" + }, + "og_image": { + "$ref": "_defs.json#/$defs/imageAsset" + }, + "twitter_card": { + "type": "string", + "enum": ["summary", "summary_large_image"] + }, + "json_ld": { + "type": "object", + "additionalProperties": true + } + } + }, + "taxonomy": { + "type": "object", + "additionalProperties": false, + "properties": { + "categories": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "series": { + "type": "string" + }, + "featured": { + "type": "boolean" + }, + "pin_until": { + "type": "string", + "format": "date-time" + } + } + }, + "relations": { + "type": "object", + "additionalProperties": false, + "properties": { + "related_services": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "related_posts": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "_defs.json#/$defs/source" + } + }, + "assets": { + "type": "object", + "additionalProperties": false, + "properties": { + "hero_image": { + "$ref": "_defs.json#/$defs/imageAsset" + }, + "attachments": { + "type": "array", + "items": { "$ref": "_defs.json#/$defs/attachment" } + } + } + }, + "audit": { + "type": "object", + "additionalProperties": false, + "properties": { + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" }, + "created_by": { "type": "string" }, + "updated_by": { "type": "string" }, + "revision": { "type": "integer", "minimum": 0 }, + "fact_checked": { "type": "boolean" }, + "legal_reviewed": { "type": "boolean" }, + "change_notes": { "type": "string" } + } + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "views": { "type": "integer", "minimum": 0 }, + "avg_read_time_sec": { "type": "integer", "minimum": 0 }, + "cta_clicks": { "type": "integer", "minimum": 0 } + } + }, + "readability": { + "type": "object", + "additionalProperties": false, + "properties": { + "reading_time_minutes_est": { "type": "integer", "minimum": 0 }, + "word_count": { "type": "integer", "minimum": 0 }, + "summary_bullets": { + "type": "array", + "items": { "type": "string" }, + "maxItems": 12 + } + } + } + } +} diff --git a/tools.go b/tools.go index 932931d..eeb7ba2 100644 --- a/tools.go +++ b/tools.go @@ -5,4 +5,5 @@ package tools import ( _ "github.com/99designs/gqlgen" _ "github.com/99designs/gqlgen/graphql/introspection" + _ "github.com/pressly/goose/v3/cmd/goose" ) diff --git a/validate.py b/validate.py new file mode 100644 index 0000000..5bf7759 --- /dev/null +++ b/validate.py @@ -0,0 +1,45 @@ +import json +import os +from jsonschema import validate +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 + +def main(): + """ + Validates the example blog posts against the blog.json schema. + """ + schemas_dir = "schemas" + content_dir = "content/blog" + + # Create a resource for each schema + blog_schema_path = os.path.join(schemas_dir, "blog.json") + with open(blog_schema_path, "r") as f: + blog_schema_resource = Resource.from_contents(json.load(f), default_specification=DRAFT202012) + + defs_schema_path = os.path.join(schemas_dir, "_defs.json") + with open(defs_schema_path, "r") as f: + defs_schema_resource = Resource.from_contents(json.load(f), default_specification=DRAFT202012) + + # Create a registry and add the resources + registry = Registry().with_resources( + [ + ("blog.json", blog_schema_resource), + ("_defs.json", defs_schema_resource), + ] + ) + + # Validate each blog post + for filename in os.listdir(content_dir): + if filename.endswith(".json"): + filepath = os.path.join(content_dir, filename) + with open(filepath, "r") as f: + instance = json.load(f) + + try: + validate(instance=instance, schema=blog_schema_resource.contents, registry=registry) + print(f"Successfully validated {filename}") + except Exception as e: + print(f"Validation failed for {filename}: {e}") + +if __name__ == "__main__": + main()