diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff33bf4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Agent Debugging Log + +## Issue: Integration Test Failures + +I've been encountering a series of integration test failures related to `unauthorized`, `forbidden`, and `directive binding is not implemented` errors. + +### Initial Investigation + +1. **`directive binding is not implemented` error:** This error was caused by the test server in `internal/adapters/graphql/integration_test.go` not being configured with the necessary validation directive. +2. **`unauthorized` and `forbidden` errors:** These errors were caused by tests that require authentication not being run with an authenticated user. +3. **Build Error:** My initial attempts to fix the test server setup introduced a build error in `cmd/api` due to a function signature mismatch in `NewServerWithAuth`. + +### Resolution Path + +1. **Fix Build Error:** I corrected the function signature in `cmd/api/server.go` to match the call site in `cmd/api/main.go`. This resolved the build error. +2. **Fix Test Server Setup:** I updated the `SetupSuite` function in `internal/adapters/graphql/integration_test.go` to register the `binding` directive, aligning the test server configuration with the production server. +3. **Fix Authentication in Tests:** The remaining `forbidden` errors are because the tests are not passing the authentication token for an admin user. I will now modify the failing tests to create an admin user and pass the token in the `executeGraphQL` function. \ No newline at end of file diff --git a/cmd/api/main.go b/cmd/api/main.go index 2588088..2df8bb8 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -13,6 +13,7 @@ import ( graph "tercul/internal/adapters/graphql" dbsql "tercul/internal/data/sql" "tercul/internal/jobs/linguistics" + "tercul/internal/observability" "tercul/internal/platform/auth" "tercul/internal/platform/config" "tercul/internal/platform/db" @@ -22,6 +23,7 @@ import ( "github.com/99designs/gqlgen/graphql/playground" "github.com/pressly/goose/v3" + "github.com/prometheus/client_golang/prometheus" "github.com/weaviate/weaviate-go-client/v5/weaviate" "gorm.io/gorm" ) @@ -54,8 +56,24 @@ func main() { // Load configuration from environment variables config.LoadConfig() - // Initialize structured logger with appropriate log level - log.SetDefaultLevel(log.InfoLevel) + // Initialize logger + log.Init("tercul-api", config.Cfg.Environment) + + // Initialize OpenTelemetry Tracer Provider + tp, err := observability.TracerProvider("tercul-api", config.Cfg.Environment) + if err != nil { + log.LogFatal("Failed to initialize OpenTelemetry tracer", log.F("error", err)) + } + defer func() { + if err := tp.Shutdown(context.Background()); err != nil { + log.LogError("Error shutting down tracer provider", log.F("error", err)) + } + }() + + // Initialize Prometheus metrics + reg := prometheus.NewRegistry() + metrics := observability.NewMetrics(reg) // Metrics are registered automatically + log.LogInfo("Starting Tercul application", log.F("environment", config.Cfg.Environment), log.F("version", "1.0.0")) @@ -106,7 +124,7 @@ func main() { } jwtManager := auth.NewJWTManager() - srv := NewServerWithAuth(resolver, jwtManager) + srv := NewServerWithAuth(resolver, jwtManager, metrics) graphQLServer := &http.Server{ Addr: config.Cfg.ServerPort, Handler: srv, @@ -121,6 +139,13 @@ func main() { } log.LogInfo("GraphQL playground created successfully", log.F("port", config.Cfg.PlaygroundPort)) + // Create metrics server + metricsServer := &http.Server{ + Addr: ":9090", + Handler: observability.PrometheusHandler(reg), + } + log.LogInfo("Metrics server created successfully", log.F("port", ":9090")) + // Start HTTP servers in goroutines go func() { log.LogInfo("Starting GraphQL server", @@ -140,6 +165,13 @@ func main() { } }() + go func() { + log.LogInfo("Starting metrics server", log.F("port", ":9090")) + if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.LogFatal("Failed to start metrics server", log.F("error", err)) + } + }() + // Wait for interrupt signal to gracefully shutdown the servers quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) @@ -161,5 +193,9 @@ func main() { log.F("error", err)) } + if err := metricsServer.Shutdown(ctx); err != nil { + log.LogError("Metrics server forced to shutdown", log.F("error", err)) + } + log.LogInfo("All servers shutdown successfully") } \ No newline at end of file diff --git a/cmd/api/server.go b/cmd/api/server.go index 9da31ce..ffdb8e6 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -3,6 +3,7 @@ package main import ( "net/http" "tercul/internal/adapters/graphql" + "tercul/internal/observability" "tercul/internal/platform/auth" "github.com/99designs/gqlgen/graphql/handler" @@ -21,18 +22,26 @@ func NewServer(resolver *graphql.Resolver) http.Handler { return mux } -// NewServerWithAuth creates a new GraphQL server with authentication middleware -func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler { +// NewServerWithAuth creates a new GraphQL server with authentication and observability middleware +func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics) http.Handler { c := graphql.Config{Resolvers: resolver} c.Directives.Binding = graphql.Binding + + // Create the server with the custom error presenter srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) + srv.SetErrorPresenter(graphql.NewErrorPresenter()) - // Apply authentication middleware to GraphQL endpoint - authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv) + // Create a middleware chain + var chain http.Handler + chain = srv + chain = auth.GraphQLAuthMiddleware(jwtManager)(chain) + chain = metrics.PrometheusMiddleware(chain) + chain = observability.TracingMiddleware(chain) + chain = observability.RequestIDMiddleware(chain) - // Create a mux to handle GraphQL endpoint only (no playground here; served separately in production) + // Create a mux to handle GraphQL endpoint mux := http.NewServeMux() - mux.Handle("/query", authHandler) + mux.Handle("/query", chain) return mux -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 06fecca..cf0b7b5 100644 --- a/go.mod +++ b/go.mod @@ -8,16 +8,23 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/go-playground/validator/v10 v10.27.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.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/pressly/goose/v3 v3.25.0 + github.com/pressly/goose/v3 v3.21.1 + github.com/prometheus/client_golang v1.20.5 github.com/redis/go-redis/v9 v9.13.0 + github.com/rs/zerolog v1.34.0 github.com/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 + github.com/weaviate/weaviate v1.33.0-rc.1 + github.com/weaviate/weaviate-go-client/v5 v5.5.0 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 golang.org/x/crypto v0.41.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 @@ -25,13 +32,13 @@ require ( ) require ( - ariga.io/atlas-go-sdk v0.5.1 // indirect 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/ClickHouse/ch-go v0.61.1 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.17.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/beorn7/perks v1.0.1 // 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 @@ -43,6 +50,8 @@ require ( 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-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // 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 @@ -60,7 +69,6 @@ require ( 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 @@ -68,18 +76,18 @@ require ( 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-colorable v0.1.14 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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 @@ -87,6 +95,8 @@ require ( 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/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // 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 @@ -105,8 +115,8 @@ require ( 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 - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.26.0 // indirect @@ -118,8 +128,8 @@ require ( 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-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.73.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect + google.golang.org/grpc v1.74.2 // 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 diff --git a/go.sum b/go.sum index 01c4c9f..907112b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -ariga.io/atlas-go-sdk v0.5.1 h1:I3iRshdwSODVWwMS4zvXObnfCQrEOY8BLRwynJQA+qE= -ariga.io/atlas-go-sdk v0.5.1/go.mod h1:UZXG++2NQCDAetk+oIitYIGpL/VsBVCt4GXbtWBA/GY= 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= @@ -19,10 +17,10 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h 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/ClickHouse/ch-go v0.61.1 h1:j5rx3qnvcnYjhnP1IdXE/vdIRQiqgwAzyqOaasA6QCw= +github.com/ClickHouse/ch-go v0.61.1/go.mod h1:myxt/JZgy2BYHFGQqzmaIpbfr5CMbs3YHVULaWQj5YU= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1 h1:ZCmAYWpu75IyEi7+Yrs/uaAjiCGY5wfW5kXo64exkX4= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1/go.mod h1:rkGTvFDTLqLIm0ma+13xmcCfr/08Gvs7KmFt1tgiWHQ= 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= @@ -47,6 +45,8 @@ github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:W github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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= @@ -64,6 +64,7 @@ github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWH 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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -100,6 +101,7 @@ 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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 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= @@ -178,6 +180,7 @@ 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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= @@ -245,8 +248,6 @@ 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= @@ -282,14 +283,17 @@ 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-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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= @@ -298,6 +302,8 @@ github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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= @@ -322,9 +328,15 @@ 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/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= -github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ= +github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 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= @@ -340,8 +352,11 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= @@ -381,10 +396,10 @@ github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsL 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/weaviate/weaviate v1.33.0-rc.1 h1:3Kol9BmA9JOj1I4vOkz0tu4A87K3dKVAnr8k8DMhBs8= +github.com/weaviate/weaviate v1.33.0-rc.1/go.mod h1:MmHF/hZDL0I8j0qAMEa9/TS4ISLaYlIp1Bc3e/n3eUU= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= 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= @@ -411,16 +426,18 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd 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.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/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= 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= @@ -500,7 +517,9 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w 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.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.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= @@ -547,8 +566,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA 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/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/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= @@ -556,8 +575,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 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/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= 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= diff --git a/internal/adapters/graphql/analytics_service_mock_test.go b/internal/adapters/graphql/analytics_service_mock_test.go new file mode 100644 index 0000000..4686c17 --- /dev/null +++ b/internal/adapters/graphql/analytics_service_mock_test.go @@ -0,0 +1,77 @@ +package graphql_test + +import ( + "context" + "tercul/internal/domain" + "tercul/internal/domain/work" + "time" + + "github.com/stretchr/testify/mock" +) + +// mockAnalyticsService is a mock implementation of the AnalyticsService interface. +type mockAnalyticsService struct { + mock.Mock +} + +func (m *mockAnalyticsService) IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error { + args := m.Called(ctx, workID, field, value) + return args.Error(0) +} + +func (m *mockAnalyticsService) IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error { + args := m.Called(ctx, translationID, field, value) + return args.Error(0) +} + +func (m *mockAnalyticsService) UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error { + args := m.Called(ctx, workID, stats) + return args.Error(0) +} + +func (m *mockAnalyticsService) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { + args := m.Called(ctx, translationID, stats) + return args.Error(0) +} + +func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { + args := m.Called(ctx, workID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*work.WorkStats), args.Error(1) +} + +func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + args := m.Called(ctx, translationID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.TranslationStats), args.Error(1) +} + +func (m *mockAnalyticsService) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) { + args := m.Called(ctx, userID, date) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.UserEngagement), args.Error(1) +} + +func (m *mockAnalyticsService) UpdateUserEngagement(ctx context.Context, userEngagement *domain.UserEngagement) error { + args := m.Called(ctx, userEngagement) + return args.Error(0) +} + +func (m *mockAnalyticsService) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error { + args := m.Called(ctx, timePeriod, trending) + return args.Error(0) +} + +func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { + args := m.Called(ctx, timePeriod, limit) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*work.Work), args.Error(1) +} \ No newline at end of file diff --git a/internal/adapters/graphql/book_integration_test.go b/internal/adapters/graphql/book_integration_test.go new file mode 100644 index 0000000..4d8bee1 --- /dev/null +++ b/internal/adapters/graphql/book_integration_test.go @@ -0,0 +1,241 @@ +package graphql_test + +import ( + "tercul/internal/adapters/graphql/model" + "tercul/internal/domain" +) + +type CreateBookResponse struct { + CreateBook model.Book `json:"createBook"` +} + +type GetBookResponse struct { + Book model.Book `json:"book"` +} + +type GetBooksResponse struct { + Books []model.Book `json:"books"` +} + +type UpdateBookResponse struct { + UpdateBook model.Book `json:"updateBook"` +} + +func (s *GraphQLIntegrationSuite) TestBookMutations() { + // Create users for testing authorization + _, readerToken := s.CreateAuthenticatedUser("bookreader", "bookreader@test.com", domain.UserRoleReader) + _, adminToken := s.CreateAuthenticatedUser("bookadmin", "bookadmin@test.com", domain.UserRoleAdmin) + + var bookID string + + s.Run("a reader can create a book", func() { + // Define the mutation + mutation := ` + mutation CreateBook($input: BookInput!) { + createBook(input: $input) { + id + name + description + language + isbn + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "input": map[string]interface{}{ + "name": "My New Book", + "description": "A book about something.", + "language": "en", + "isbn": "978-3-16-148410-0", + }, + } + + // Execute the mutation + response, err := executeGraphQL[CreateBookResponse](s, mutation, variables, &readerToken) + 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.CreateBook.ID, "Book ID should not be nil") + bookID = response.Data.CreateBook.ID + s.Equal("My New Book", response.Data.CreateBook.Name) + s.Equal("A book about something.", *response.Data.CreateBook.Description) + s.Equal("en", response.Data.CreateBook.Language) + s.Equal("978-3-16-148410-0", *response.Data.CreateBook.Isbn) + }) + + s.Run("a reader is forbidden from updating a book", func() { + // Define the mutation + mutation := ` + mutation UpdateBook($id: ID!, $input: BookInput!) { + updateBook(id: $id, input: $input) { + id + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": bookID, + "input": map[string]interface{}{ + "name": "Updated Book Name", + "language": "en", + }, + } + + // Execute the mutation with the reader's token + response, err := executeGraphQL[any](s, mutation, variables, &readerToken) + s.Require().NoError(err) + s.Require().NotNil(response.Errors) + }) + + s.Run("an admin can update a book", func() { + // Define the mutation + mutation := ` + mutation UpdateBook($id: ID!, $input: BookInput!) { + updateBook(id: $id, input: $input) { + id + name + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": bookID, + "input": map[string]interface{}{ + "name": "Updated Book Name by Admin", + "language": "en", + }, + } + + // Execute the mutation with the admin's token + response, err := executeGraphQL[UpdateBookResponse](s, mutation, variables, &adminToken) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") + }) + + s.Run("a reader is forbidden from deleting a book", func() { + // Define the mutation + mutation := ` + mutation DeleteBook($id: ID!) { + deleteBook(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": bookID, + } + + // Execute the mutation with the reader's token + response, err := executeGraphQL[any](s, mutation, variables, &readerToken) + s.Require().NoError(err) + s.Require().NotNil(response.Errors) + }) + + s.Run("an admin can delete a book", func() { + // Define the mutation + mutation := ` + mutation DeleteBook($id: ID!) { + deleteBook(id: $id) + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": bookID, + } + + // Execute the mutation with the admin's token + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) + s.Require().NoError(err) + s.Require().Nil(response.Errors) + s.True(response.Data.(map[string]interface{})["deleteBook"].(bool)) + }) +} + +func (s *GraphQLIntegrationSuite) TestBookQueries() { + // Create a book to query + _, adminToken := s.CreateAuthenticatedUser("bookadmin2", "bookadmin2@test.com", domain.UserRoleAdmin) + createMutation := ` + mutation CreateBook($input: BookInput!) { + createBook(input: $input) { + id + } + } + ` + createVariables := map[string]interface{}{ + "input": map[string]interface{}{ + "name": "Queryable Book", + "description": "A book to be queried.", + "language": "en", + "isbn": "978-0-306-40615-7", + }, + } + createResponse, err := executeGraphQL[CreateBookResponse](s, createMutation, createVariables, &adminToken) + s.Require().NoError(err) + bookID := createResponse.Data.CreateBook.ID + + s.Run("should get a book by ID", func() { + // Define the query + query := ` + query GetBook($id: ID!) { + book(id: $id) { + id + name + description + } + } + ` + + // Define the variables + variables := map[string]interface{}{ + "id": bookID, + } + + // Execute the query + response, err := executeGraphQL[GetBookResponse](s, query, variables, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL query should not return errors") + + // Verify the response + s.Equal(bookID, response.Data.Book.ID) + s.Equal("Queryable Book", response.Data.Book.Name) + s.Equal("A book to be queried.", *response.Data.Book.Description) + }) + + s.Run("should get a list of books", func() { + // Define the query + query := ` + query GetBooks { + books { + id + name + } + } + ` + + // Execute the query + response, err := executeGraphQL[GetBooksResponse](s, query, nil, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL query should not return errors") + + // Verify the response + s.True(len(response.Data.Books) >= 1) + foundBook := false + for _, book := range response.Data.Books { + if book.ID == bookID { + foundBook = true + break + } + } + s.True(foundBook, "The created book should be in the list") + }) +} \ No newline at end of file diff --git a/internal/adapters/graphql/errors.go b/internal/adapters/graphql/errors.go new file mode 100644 index 0000000..23d58e8 --- /dev/null +++ b/internal/adapters/graphql/errors.go @@ -0,0 +1,49 @@ +package graphql + +import ( + "context" + "errors" + "tercul/internal/domain" + + "github.com/99designs/gqlgen/graphql" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +// NewErrorPresenter creates a custom error presenter for gqlgen. +func NewErrorPresenter() graphql.ErrorPresenterFunc { + return func(ctx context.Context, e error) *gqlerror.Error { + gqlErr := graphql.DefaultErrorPresenter(ctx, e) + + // Unwrap the error to find the root cause. + originalErr := errors.Unwrap(e) + if originalErr == nil { + originalErr = e + } + + // Check for custom application errors and format them. + switch { + case errors.Is(originalErr, domain.ErrNotFound): + gqlErr.Message = "The requested resource was not found." + gqlErr.Extensions = map[string]interface{}{"code": "NOT_FOUND"} + case errors.Is(originalErr, domain.ErrUnauthorized): + gqlErr.Message = "You must be logged in to perform this action." + gqlErr.Extensions = map[string]interface{}{"code": "UNAUTHENTICATED"} + case errors.Is(originalErr, domain.ErrForbidden): + gqlErr.Message = "You are not authorized to perform this action." + gqlErr.Extensions = map[string]interface{}{"code": "FORBIDDEN"} + case errors.Is(originalErr, domain.ErrValidation): + // Keep the detailed message from the validation error. + gqlErr.Message = originalErr.Error() + gqlErr.Extensions = map[string]interface{}{"code": "VALIDATION_FAILED"} + case errors.Is(originalErr, domain.ErrConflict): + gqlErr.Message = "A conflict occurred with the current state of the resource." + gqlErr.Extensions = map[string]interface{}{"code": "CONFLICT"} + default: + // For all other errors, return a generic message to avoid leaking implementation details. + gqlErr.Message = "An unexpected internal error occurred." + gqlErr.Extensions = map[string]interface{}{"code": "INTERNAL_SERVER_ERROR"} + } + + return gqlErr + } +} \ No newline at end of file diff --git a/internal/adapters/graphql/generated.go b/internal/adapters/graphql/generated.go index af9d3f9..5ecb960 100644 --- a/internal/adapters/graphql/generated.go +++ b/internal/adapters/graphql/generated.go @@ -84,10 +84,13 @@ type ComplexityRoot struct { } Book struct { + Authors func(childComplexity int) int Copyright func(childComplexity int) int CopyrightClaims func(childComplexity int) int CreatedAt func(childComplexity int) int + Description func(childComplexity int) int ID func(childComplexity int) int + Isbn func(childComplexity int) int Language func(childComplexity int) int Name func(childComplexity int) int Stats func(childComplexity int) int @@ -276,6 +279,7 @@ type ComplexityRoot struct { AddWorkToCollection func(childComplexity int, collectionID string, workID string) int ChangePassword func(childComplexity int, currentPassword string, newPassword string) int CreateAuthor func(childComplexity int, input model.AuthorInput) int + CreateBook func(childComplexity int, input model.BookInput) int CreateBookmark func(childComplexity int, input model.BookmarkInput) int CreateCollection func(childComplexity int, input model.CollectionInput) int CreateComment func(childComplexity int, input model.CommentInput) int @@ -284,6 +288,7 @@ type ComplexityRoot struct { CreateTranslation func(childComplexity int, input model.TranslationInput) int CreateWork func(childComplexity int, input model.WorkInput) int DeleteAuthor func(childComplexity int, id string) int + DeleteBook func(childComplexity int, id string) int DeleteBookmark func(childComplexity int, id string) int DeleteCollection func(childComplexity int, id string) int DeleteComment func(childComplexity int, id string) int @@ -302,6 +307,7 @@ type ComplexityRoot struct { ResetPassword func(childComplexity int, token string, newPassword string) int ReviewContribution func(childComplexity int, id string, status model.ContributionStatus, feedback *string) int UpdateAuthor func(childComplexity int, id string, input model.AuthorInput) int + UpdateBook func(childComplexity int, id string, input model.BookInput) int UpdateCollection func(childComplexity int, id string, input model.CollectionInput) int UpdateComment func(childComplexity int, id string, input model.CommentInput) int UpdateContribution func(childComplexity int, id string, input model.ContributionInput) int @@ -335,6 +341,8 @@ type ComplexityRoot struct { Query struct { Author func(childComplexity int, id string) int Authors func(childComplexity int, limit *int32, offset *int32, search *string, countryID *string) int + Book func(childComplexity int, id string) int + Books func(childComplexity int, limit *int32, offset *int32) int Categories func(childComplexity int, limit *int32, offset *int32) int Category func(childComplexity int, id string) int Collection func(childComplexity int, id string) int @@ -563,6 +571,9 @@ type MutationResolver interface { CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) DeleteTranslation(ctx context.Context, id string) (bool, error) + CreateBook(ctx context.Context, input model.BookInput) (*model.Book, error) + UpdateBook(ctx context.Context, id string, input model.BookInput) (*model.Book, error) + DeleteBook(ctx context.Context, id string) (bool, error) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) DeleteAuthor(ctx context.Context, id string) (bool, error) @@ -598,6 +609,8 @@ type QueryResolver interface { Works(ctx context.Context, limit *int32, offset *int32, language *string, authorID *string, categoryID *string, tagID *string, search *string) ([]*model.Work, error) Translation(ctx context.Context, id string) (*model.Translation, error) Translations(ctx context.Context, workID string, language *string, limit *int32, offset *int32) ([]*model.Translation, error) + Book(ctx context.Context, id string) (*model.Book, error) + Books(ctx context.Context, limit *int32, offset *int32) ([]*model.Book, error) Author(ctx context.Context, id string) (*model.Author, error) Authors(ctx context.Context, limit *int32, offset *int32, search *string, countryID *string) ([]*model.Author, error) User(ctx context.Context, id string) (*model.User, error) @@ -819,6 +832,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Author.Works(childComplexity), true + case "Book.authors": + if e.complexity.Book.Authors == nil { + break + } + + return e.complexity.Book.Authors(childComplexity), true + case "Book.copyright": if e.complexity.Book.Copyright == nil { break @@ -840,6 +860,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Book.CreatedAt(childComplexity), true + case "Book.description": + if e.complexity.Book.Description == nil { + break + } + + return e.complexity.Book.Description(childComplexity), true + case "Book.id": if e.complexity.Book.ID == nil { break @@ -847,6 +874,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Book.ID(childComplexity), true + case "Book.isbn": + if e.complexity.Book.Isbn == nil { + break + } + + return e.complexity.Book.Isbn(childComplexity), true + case "Book.language": if e.complexity.Book.Language == nil { break @@ -1800,6 +1834,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.CreateAuthor(childComplexity, args["input"].(model.AuthorInput)), true + case "Mutation.createBook": + if e.complexity.Mutation.CreateBook == nil { + break + } + + args, err := ec.field_Mutation_createBook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CreateBook(childComplexity, args["input"].(model.BookInput)), true + case "Mutation.createBookmark": if e.complexity.Mutation.CreateBookmark == nil { break @@ -1896,6 +1942,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.DeleteAuthor(childComplexity, args["id"].(string)), true + case "Mutation.deleteBook": + if e.complexity.Mutation.DeleteBook == nil { + break + } + + args, err := ec.field_Mutation_deleteBook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DeleteBook(childComplexity, args["id"].(string)), true + case "Mutation.deleteBookmark": if e.complexity.Mutation.DeleteBookmark == nil { break @@ -2102,6 +2160,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.UpdateAuthor(childComplexity, args["id"].(string), args["input"].(model.AuthorInput)), true + case "Mutation.updateBook": + if e.complexity.Mutation.UpdateBook == nil { + break + } + + args, err := ec.field_Mutation_updateBook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateBook(childComplexity, args["id"].(string), args["input"].(model.BookInput)), true + case "Mutation.updateCollection": if e.complexity.Mutation.UpdateCollection == nil { break @@ -2320,6 +2390,30 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.Authors(childComplexity, args["limit"].(*int32), args["offset"].(*int32), args["search"].(*string), args["countryId"].(*string)), true + case "Query.book": + if e.complexity.Query.Book == nil { + break + } + + args, err := ec.field_Query_book_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Book(childComplexity, args["id"].(string)), true + + case "Query.books": + if e.complexity.Query.Books == nil { + break + } + + args, err := ec.field_Query_books_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Books(childComplexity, args["limit"].(*int32), args["offset"].(*int32)), true + case "Query.categories": if e.complexity.Query.Categories == nil { break @@ -3621,6 +3715,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)} inputUnmarshalMap := graphql.BuildUnmarshalerMap( ec.unmarshalInputAuthorInput, + ec.unmarshalInputBookInput, ec.unmarshalInputBookmarkInput, ec.unmarshalInputCollectionInput, ec.unmarshalInputCommentInput, @@ -3802,6 +3897,17 @@ func (ec *executionContext) field_Mutation_createAuthor_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_createBook_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.unmarshalNBookInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, 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{} @@ -3890,6 +3996,17 @@ func (ec *executionContext) field_Mutation_deleteAuthor_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_deleteBook_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, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, 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{} @@ -4091,6 +4208,22 @@ func (ec *executionContext) field_Mutation_updateAuthor_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_updateBook_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, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNBookInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookInput) + if err != nil { + return nil, err + } + args["input"] = arg1 + return args, 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{} @@ -4257,6 +4390,33 @@ func (ec *executionContext) field_Query_authors_args(ctx context.Context, rawArg return args, nil } +func (ec *executionContext) field_Query_book_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, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_books_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, "limit", ec.unmarshalOInt2ᚖint32) + if err != nil { + return nil, err + } + args["limit"] = arg0 + 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_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -5709,12 +5869,18 @@ func (ec *executionContext) fieldContext_Author_books(_ context.Context, field g return ec.fieldContext_Book_name(ctx, field) case "language": return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) case "createdAt": return ec.fieldContext_Book_createdAt(ctx, field) case "updatedAt": return ec.fieldContext_Book_updatedAt(ctx, field) case "works": return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) case "stats": return ec.fieldContext_Book_stats(ctx, field) case "copyright": @@ -6220,6 +6386,88 @@ func (ec *executionContext) fieldContext_Book_language(_ context.Context, field return fc, nil } +func (ec *executionContext) _Book_description(ctx context.Context, field graphql.CollectedField, obj *model.Book) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Book_description(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.Description, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Book_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Book", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Book_isbn(ctx context.Context, field graphql.CollectedField, obj *model.Book) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Book_isbn(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.Isbn, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Book_isbn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Book", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Book_createdAt(ctx context.Context, field graphql.CollectedField, obj *model.Book) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Book_createdAt(ctx, field) if err != nil { @@ -6403,6 +6651,81 @@ func (ec *executionContext) fieldContext_Book_works(_ context.Context, field gra return fc, nil } +func (ec *executionContext) _Book_authors(ctx context.Context, field graphql.CollectedField, obj *model.Book) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Book_authors(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.Authors, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]*model.Author) + fc.Result = res + return ec.marshalOAuthor2ᚕᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAuthorᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Book_authors(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Book", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Author_id(ctx, field) + case "name": + return ec.fieldContext_Author_name(ctx, field) + case "language": + return ec.fieldContext_Author_language(ctx, field) + case "biography": + return ec.fieldContext_Author_biography(ctx, field) + case "birthDate": + return ec.fieldContext_Author_birthDate(ctx, field) + case "deathDate": + return ec.fieldContext_Author_deathDate(ctx, field) + case "createdAt": + return ec.fieldContext_Author_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Author_updatedAt(ctx, field) + case "works": + return ec.fieldContext_Author_works(ctx, field) + case "books": + return ec.fieldContext_Author_books(ctx, field) + case "country": + return ec.fieldContext_Author_country(ctx, field) + case "city": + return ec.fieldContext_Author_city(ctx, field) + case "place": + return ec.fieldContext_Author_place(ctx, field) + case "address": + return ec.fieldContext_Author_address(ctx, field) + case "copyrightClaims": + return ec.fieldContext_Author_copyrightClaims(ctx, field) + case "copyright": + return ec.fieldContext_Author_copyright(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Author", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Book_stats(ctx context.Context, field graphql.CollectedField, obj *model.Book) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Book_stats(ctx, field) if err != nil { @@ -6803,12 +7126,18 @@ func (ec *executionContext) fieldContext_BookStats_book(_ context.Context, field return ec.fieldContext_Book_name(ctx, field) case "language": return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) case "createdAt": return ec.fieldContext_Book_createdAt(ctx, field) case "updatedAt": return ec.fieldContext_Book_updatedAt(ctx, field) case "works": return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) case "stats": return ec.fieldContext_Book_stats(ctx, field) case "copyright": @@ -10568,12 +10897,18 @@ func (ec *executionContext) fieldContext_Copyright_books(_ context.Context, fiel return ec.fieldContext_Book_name(ctx, field) case "language": return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) case "createdAt": return ec.fieldContext_Book_createdAt(ctx, field) case "updatedAt": return ec.fieldContext_Book_updatedAt(ctx, field) case "works": return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) case "stats": return ec.fieldContext_Book_stats(ctx, field) case "copyright": @@ -11030,12 +11365,18 @@ func (ec *executionContext) fieldContext_CopyrightClaim_book(_ context.Context, return ec.fieldContext_Book_name(ctx, field) case "language": return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) case "createdAt": return ec.fieldContext_Book_createdAt(ctx, field) case "updatedAt": return ec.fieldContext_Book_updatedAt(ctx, field) case "works": return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) case "stats": return ec.fieldContext_Book_stats(ctx, field) case "copyright": @@ -14266,6 +14607,223 @@ func (ec *executionContext) fieldContext_Mutation_deleteTranslation(ctx context. return fc, nil } +func (ec *executionContext) _Mutation_createBook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_createBook(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.Mutation().CreateBook(rctx, fc.Args["input"].(model.BookInput)) + }) + 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.Book) + fc.Result = res + return ec.marshalNBook2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBook(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_createBook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + 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_Book_id(ctx, field) + case "name": + return ec.fieldContext_Book_name(ctx, field) + case "language": + return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) + case "createdAt": + return ec.fieldContext_Book_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Book_updatedAt(ctx, field) + case "works": + return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) + case "stats": + return ec.fieldContext_Book_stats(ctx, field) + case "copyright": + return ec.fieldContext_Book_copyright(ctx, field) + case "copyrightClaims": + return ec.fieldContext_Book_copyrightClaims(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Book", 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_Mutation_createBook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_updateBook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_updateBook(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.Mutation().UpdateBook(rctx, fc.Args["id"].(string), fc.Args["input"].(model.BookInput)) + }) + 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.Book) + fc.Result = res + return ec.marshalNBook2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBook(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_updateBook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + 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_Book_id(ctx, field) + case "name": + return ec.fieldContext_Book_name(ctx, field) + case "language": + return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) + case "createdAt": + return ec.fieldContext_Book_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Book_updatedAt(ctx, field) + case "works": + return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) + case "stats": + return ec.fieldContext_Book_stats(ctx, field) + case "copyright": + return ec.fieldContext_Book_copyright(ctx, field) + case "copyrightClaims": + return ec.fieldContext_Book_copyrightClaims(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Book", 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_Mutation_updateBook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deleteBook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_deleteBook(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.Mutation().DeleteBook(rctx, fc.Args["id"].(string)) + }) + 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_deleteBook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + 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_Mutation_deleteBook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createAuthor(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_createAuthor(ctx, field) if err != nil { @@ -17328,6 +17886,165 @@ func (ec *executionContext) fieldContext_Query_translations(ctx context.Context, return fc, nil } +func (ec *executionContext) _Query_book(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_book(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().Book(rctx, fc.Args["id"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*model.Book) + fc.Result = res + return ec.marshalOBook2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBook(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_book(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_Book_id(ctx, field) + case "name": + return ec.fieldContext_Book_name(ctx, field) + case "language": + return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) + case "createdAt": + return ec.fieldContext_Book_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Book_updatedAt(ctx, field) + case "works": + return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) + case "stats": + return ec.fieldContext_Book_stats(ctx, field) + case "copyright": + return ec.fieldContext_Book_copyright(ctx, field) + case "copyrightClaims": + return ec.fieldContext_Book_copyrightClaims(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Book", 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_book_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_books(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_books(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().Books(rctx, fc.Args["limit"].(*int32), fc.Args["offset"].(*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.Book) + fc.Result = res + return ec.marshalNBook2ᚕᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_books(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_Book_id(ctx, field) + case "name": + return ec.fieldContext_Book_name(ctx, field) + case "language": + return ec.fieldContext_Book_language(ctx, field) + case "description": + return ec.fieldContext_Book_description(ctx, field) + case "isbn": + return ec.fieldContext_Book_isbn(ctx, field) + case "createdAt": + return ec.fieldContext_Book_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Book_updatedAt(ctx, field) + case "works": + return ec.fieldContext_Book_works(ctx, field) + case "authors": + return ec.fieldContext_Book_authors(ctx, field) + case "stats": + return ec.fieldContext_Book_stats(ctx, field) + case "copyright": + return ec.fieldContext_Book_copyright(ctx, field) + case "copyrightClaims": + return ec.fieldContext_Book_copyrightClaims(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Book", 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_books_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query_author(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_author(ctx, field) if err != nil { @@ -28825,18 +29542,58 @@ func (ec *executionContext) unmarshalInputAuthorInput(ctx context.Context, obj a switch k { case "name": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,min=2") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Name = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Name = data case "language": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("language")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,len=2") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Language = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Language = data case "biography": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("biography")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) @@ -28892,6 +29649,61 @@ func (ec *executionContext) unmarshalInputAuthorInput(ctx context.Context, obj a return it, nil } +func (ec *executionContext) unmarshalInputBookInput(ctx context.Context, obj any) (model.BookInput, error) { + var it model.BookInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"name", "language", "description", "isbn", "authorIds"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "name": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Name = data + case "language": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("language")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Language = data + case "description": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("description")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Description = data + case "isbn": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("isbn")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Isbn = data + case "authorIds": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("authorIds")) + data, err := ec.unmarshalOID2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.AuthorIds = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputBookmarkInput(ctx context.Context, obj any) (model.BookmarkInput, error) { var it model.BookmarkInput asMap := map[string]any{} @@ -29127,18 +29939,58 @@ func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj an switch k { case "email": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,email") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Email = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, 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 + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Password = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Password = data } } @@ -29161,39 +30013,139 @@ func (ec *executionContext) unmarshalInputRegisterInput(ctx context.Context, obj switch k { case "username": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("username")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,min=3,max=50") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Username = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Username = data case "email": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,email") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Email = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, 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 + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,min=8") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Password = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Password = data case "firstName": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("firstName")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.FirstName = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.FirstName = data case "lastName": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("lastName")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.LastName = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.LastName = data } } @@ -29278,18 +30230,58 @@ func (ec *executionContext) unmarshalInputTranslationInput(ctx context.Context, switch k { case "name": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,min=2") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Name = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Name = data case "language": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("language")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,len=2") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Language = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Language = data case "content": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("content")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) @@ -29444,18 +30436,58 @@ func (ec *executionContext) unmarshalInputWorkInput(ctx context.Context, obj any switch k { case "name": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,min=2") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Name = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Name = data case "language": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("language")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err + directive0 := func(ctx context.Context) (any, error) { return ec.unmarshalNString2string(ctx, v) } + + directive1 := func(ctx context.Context) (any, error) { + constraint, err := ec.unmarshalNString2string(ctx, "required,len=2") + if err != nil { + var zeroVal string + return zeroVal, err + } + if ec.directives.Binding == nil { + var zeroVal string + return zeroVal, errors.New("directive binding is not implemented") + } + return ec.directives.Binding(ctx, obj, directive0, constraint) + } + + tmp, err := directive1(ctx) + if err != nil { + return it, graphql.ErrorOnPath(ctx, err) + } + if data, ok := tmp.(string); ok { + it.Language = data + } else { + err := fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return it, graphql.ErrorOnPath(ctx, err) } - it.Language = data case "content": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("content")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) @@ -29711,6 +30743,10 @@ func (ec *executionContext) _Book(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { out.Invalids++ } + case "description": + out.Values[i] = ec._Book_description(ctx, field, obj) + case "isbn": + out.Values[i] = ec._Book_isbn(ctx, field, obj) case "createdAt": out.Values[i] = ec._Book_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -29723,6 +30759,8 @@ func (ec *executionContext) _Book(ctx context.Context, sel ast.SelectionSet, obj } case "works": out.Values[i] = ec._Book_works(ctx, field, obj) + case "authors": + out.Values[i] = ec._Book_authors(ctx, field, obj) case "stats": out.Values[i] = ec._Book_stats(ctx, field, obj) case "copyright": @@ -30909,6 +31947,27 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createBook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createBook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "updateBook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateBook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deleteBook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteBook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createAuthor": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createAuthor(ctx, field) @@ -31364,6 +32423,47 @@ 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 "book": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_book(ctx, field) + 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 "books": + 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_books(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 "author": field := field @@ -33211,6 +34311,54 @@ func (ec *executionContext) unmarshalNAuthorInput2terculᚋinternalᚋadapters return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalNBook2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBook(ctx context.Context, sel ast.SelectionSet, v model.Book) graphql.Marshaler { + return ec._Book(ctx, sel, &v) +} + +func (ec *executionContext) marshalNBook2ᚕᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.Book) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNBook2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBook(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) marshalNBook2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBook(ctx context.Context, sel ast.SelectionSet, v *model.Book) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -33221,6 +34369,11 @@ func (ec *executionContext) marshalNBook2ᚖterculᚋinternalᚋadaptersᚋgraph return ec._Book(ctx, sel, v) } +func (ec *executionContext) unmarshalNBookInput2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookInput(ctx context.Context, v any) (model.BookInput, error) { + res, err := ec.unmarshalInputBookInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNBookmark2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐBookmark(ctx context.Context, sel ast.SelectionSet, v model.Bookmark) graphql.Marshaler { return ec._Bookmark(ctx, sel, &v) } diff --git a/internal/adapters/graphql/graphql_test_utils_test.go b/internal/adapters/graphql/graphql_test_utils_test.go index 7f31df8..46eefcd 100644 --- a/internal/adapters/graphql/graphql_test_utils_test.go +++ b/internal/adapters/graphql/graphql_test_utils_test.go @@ -2,6 +2,7 @@ package graphql_test import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -27,7 +28,7 @@ type graphQLTestServer interface { } // executeGraphQL executes a GraphQL query against a test server and decodes the response. -func executeGraphQL[T any](s graphQLTestServer, query string, variables map[string]interface{}, token *string) (*GraphQLResponse[T], error) { +func executeGraphQL[T any](s graphQLTestServer, query string, variables map[string]interface{}, token *string, ctx ...context.Context) (*GraphQLResponse[T], error) { request := GraphQLRequest{ Query: query, Variables: variables, @@ -38,7 +39,14 @@ func executeGraphQL[T any](s graphQLTestServer, query string, variables map[stri return nil, err } - req, err := http.NewRequest("POST", s.getURL(), bytes.NewBuffer(requestBody)) + var reqCtx context.Context + if len(ctx) > 0 { + reqCtx = ctx[0] + } else { + reqCtx = context.Background() + } + + req, err := http.NewRequestWithContext(reqCtx, "POST", s.getURL(), bytes.NewBuffer(requestBody)) if err != nil { return nil, err } diff --git a/internal/adapters/graphql/integration_test.go b/internal/adapters/graphql/integration_test.go index cad482a..f565bea 100644 --- a/internal/adapters/graphql/integration_test.go +++ b/internal/adapters/graphql/integration_test.go @@ -18,10 +18,12 @@ import ( "tercul/internal/app/translation" "tercul/internal/domain" "tercul/internal/domain/work" + "tercul/internal/observability" platform_auth "tercul/internal/platform/auth" "tercul/internal/testutil" "github.com/99designs/gqlgen/graphql/handler" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/suite" ) @@ -48,14 +50,21 @@ func (s *GraphQLIntegrationSuite) CreateAuthenticatedUser(username, email string // Update user role if necessary user := authResponse.User + token := authResponse.Token if user.Role != role { // This part is tricky. There is no UpdateUserRole command. // For a test, I can update the DB directly. s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role) user.Role = role + + // Re-generate token with the new role + jwtManager := platform_auth.NewJWTManager() + newToken, err := jwtManager.GenerateToken(user) + s.Require().NoError(err) + token = newToken } - return user, authResponse.Token + return user, token } // SetupSuite sets up the test suite @@ -64,16 +73,27 @@ func (s *GraphQLIntegrationSuite) SetupSuite() { // Create GraphQL server with the test resolver resolver := &graph.Resolver{App: s.App} - srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver})) + c := graph.Config{Resolvers: resolver} + c.Directives.Binding = graph.Binding // Register the binding directive + + // Create the server with the custom error presenter + srv := handler.NewDefaultServer(graph.NewExecutableSchema(c)) + srv.SetErrorPresenter(graph.NewErrorPresenter()) // Create JWT manager and middleware jwtManager := platform_auth.NewJWTManager() - authMiddleware := platform_auth.GraphQLAuthMiddleware(jwtManager) + reg := prometheus.NewRegistry() + metrics := observability.NewMetrics(reg) - s.server = httptest.NewServer(authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - srv.ServeHTTP(w, r) - }))) + // Create a middleware chain + var chain http.Handler + chain = srv + chain = platform_auth.GraphQLAuthMiddleware(jwtManager)(chain) + chain = metrics.PrometheusMiddleware(chain) + chain = observability.TracingMiddleware(chain) + chain = observability.RequestIDMiddleware(chain) + s.server = httptest.NewServer(chain) s.client = s.server.Client() } @@ -225,7 +245,8 @@ func (s *GraphQLIntegrationSuite) TestCreateWork() { } // Execute the mutation - response, err := executeGraphQL[CreateWorkResponse](s, mutation, variables, nil) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) + response, err := executeGraphQL[CreateWorkResponse](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") @@ -330,7 +351,8 @@ func (s *GraphQLIntegrationSuite) TestCreateWorkValidation() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") @@ -362,7 +384,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateWorkValidation() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") @@ -390,7 +413,8 @@ func (s *GraphQLIntegrationSuite) TestCreateAuthorValidation() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") @@ -423,7 +447,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") @@ -455,7 +480,8 @@ func (s *GraphQLIntegrationSuite) TestCreateTranslationValidation() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") @@ -496,7 +522,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") @@ -508,6 +535,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() { s.Run("should delete a work", func() { // Arrange work := s.CreateTestWork("Test Work", "en", "Test content") + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) // Define the mutation mutation := ` @@ -522,7 +550,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") @@ -540,6 +568,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() { // Arrange createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) s.Require().NoError(err) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) // Define the mutation mutation := ` @@ -554,7 +583,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") @@ -579,6 +608,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() { TranslatableType: "works", }) s.Require().NoError(err) + _, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin) // Define the mutation mutation := ` @@ -593,7 +623,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() { } // Execute the mutation - response, err := executeGraphQL[any](s, mutation, variables, nil) + response, err := executeGraphQL[any](s, mutation, variables, &adminToken) s.Require().NoError(err) s.Require().NotNil(response) s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") diff --git a/internal/adapters/graphql/like_repo_mock_test.go b/internal/adapters/graphql/like_repo_mock_test.go new file mode 100644 index 0000000..9c110a5 --- /dev/null +++ b/internal/adapters/graphql/like_repo_mock_test.go @@ -0,0 +1,86 @@ +package graphql_test + +import ( + "context" + "tercul/internal/domain" + + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +// mockLikeRepository is a mock implementation of the LikeRepository interface. +type mockLikeRepository struct { + mock.Mock +} + +func (m *mockLikeRepository) Create(ctx context.Context, entity *domain.Like) error { + args := m.Called(ctx, entity) + return args.Error(0) +} +func (m *mockLikeRepository) GetByID(ctx context.Context, id uint) (*domain.Like, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Like), args.Error(1) +} +func (m *mockLikeRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} +func (m *mockLikeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { + panic("not implemented") +} +func (m *mockLikeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { + panic("not implemented") +} +func (m *mockLikeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { + panic("not implemented") +} +func (m *mockLikeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { + panic("not implemented") +} + +// Implement the rest of the BaseRepository methods as needed, or panic if they are not expected to be called. +func (m *mockLikeRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { + return m.Create(ctx, entity) +} +func (m *mockLikeRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Like, error) { + return m.GetByID(ctx, id) +} +func (m *mockLikeRepository) Update(ctx context.Context, entity *domain.Like) error { + args := m.Called(ctx, entity) + return args.Error(0) +} +func (m *mockLikeRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { + return m.Update(ctx, entity) +} +func (m *mockLikeRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + return m.Delete(ctx, id) +} +func (m *mockLikeRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Like], error) { + panic("not implemented") +} +func (m *mockLikeRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Like, error) { + panic("not implemented") +} +func (m *mockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { panic("not implemented") } +func (m *mockLikeRepository) Count(ctx context.Context) (int64, error) { + panic("not implemented") +} +func (m *mockLikeRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + panic("not implemented") +} +func (m *mockLikeRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Like, error) { + return m.GetByID(ctx, id) +} +func (m *mockLikeRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Like, error) { + panic("not implemented") +} +func (m *mockLikeRepository) Exists(ctx context.Context, id uint) (bool, error) { + panic("not implemented") +} +func (m *mockLikeRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } +func (m *mockLikeRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} \ No newline at end of file diff --git a/internal/adapters/graphql/like_resolvers_unit_test.go b/internal/adapters/graphql/like_resolvers_unit_test.go index 469ec25..0db5854 100644 --- a/internal/adapters/graphql/like_resolvers_unit_test.go +++ b/internal/adapters/graphql/like_resolvers_unit_test.go @@ -13,7 +13,6 @@ import ( "tercul/internal/app/like" "tercul/internal/domain" platform_auth "tercul/internal/platform/auth" - "tercul/internal/testutil" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -23,20 +22,20 @@ import ( type LikeResolversUnitSuite struct { suite.Suite resolver *graphql.Resolver - mockLikeRepo *testutil.MockLikeRepository - mockWorkRepo *testutil.MockWorkRepository - mockAnalyticsSvc *testutil.MockAnalyticsService + mockLikeRepo *mockLikeRepository + mockWorkRepo *mockWorkRepository + mockAnalyticsSvc *mockAnalyticsService } func (s *LikeResolversUnitSuite) SetupTest() { // 1. Create mock repositories - s.mockLikeRepo = new(testutil.MockLikeRepository) - s.mockWorkRepo = new(testutil.MockWorkRepository) - s.mockAnalyticsSvc = new(testutil.MockAnalyticsService) + s.mockLikeRepo = new(mockLikeRepository) + s.mockWorkRepo = new(mockWorkRepository) + s.mockAnalyticsSvc = new(mockAnalyticsService) // 2. Create real services with mock repositories likeService := like.NewService(s.mockLikeRepo) - analyticsService := analytics.NewService(s.mockAnalyticsSvc, nil, nil, nil, nil) + analyticsService := analytics.NewService(s.mockAnalyticsSvc, nil, nil, s.mockWorkRepo, nil) // 3. Create the resolver with the services s.resolver = &graphql.Resolver{ diff --git a/internal/adapters/graphql/model/models_gen.go b/internal/adapters/graphql/model/models_gen.go index eb96721..7697dad 100644 --- a/internal/adapters/graphql/model/models_gen.go +++ b/internal/adapters/graphql/model/models_gen.go @@ -60,14 +60,25 @@ type Book struct { ID string `json:"id"` Name string `json:"name"` Language string `json:"language"` + Description *string `json:"description,omitempty"` + Isbn *string `json:"isbn,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Works []*Work `json:"works,omitempty"` + Authors []*Author `json:"authors,omitempty"` Stats *BookStats `json:"stats,omitempty"` Copyright *Copyright `json:"copyright,omitempty"` CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,omitempty"` } +type BookInput struct { + Name string `json:"name"` + Language string `json:"language"` + Description *string `json:"description,omitempty"` + Isbn *string `json:"isbn,omitempty"` + AuthorIds []string `json:"authorIds,omitempty"` +} + type BookStats struct { ID string `json:"id"` Sales int32 `json:"sales"` diff --git a/internal/adapters/graphql/schema.graphqls b/internal/adapters/graphql/schema.graphqls index 6ee2c6f..00567bc 100644 --- a/internal/adapters/graphql/schema.graphqls +++ b/internal/adapters/graphql/schema.graphqls @@ -127,14 +127,25 @@ type Book { id: ID! name: String! language: String! + description: String + isbn: String createdAt: String! updatedAt: String! works: [Work!] + authors: [Author!] stats: BookStats copyright: Copyright copyrightClaims: [CopyrightClaim!] } +input BookInput { + name: String! + language: String! + description: String + isbn: String + authorIds: [ID!] +} + type Collection { id: ID! name: String! @@ -453,8 +464,6 @@ type Edge { scalar JSON -directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION - # Queries type Query { # Work queries @@ -478,6 +487,10 @@ type Query { offset: Int ): [Translation!]! + # Book queries + book(id: ID!): Book + books(limit: Int, offset: Int): [Book!]! + # Author queries author(id: ID!): Author authors( @@ -567,6 +580,11 @@ type Mutation { createTranslation(input: TranslationInput!): Translation! updateTranslation(id: ID!, input: TranslationInput!): Translation! deleteTranslation(id: ID!): Boolean! + + # Book mutations + createBook(input: BookInput!): Book! + updateBook(id: ID!, input: BookInput!): Book! + deleteBook(id: ID!): Boolean! # Author mutations createAuthor(input: AuthorInput!): Author! @@ -618,16 +636,16 @@ type Mutation { # Input types input LoginInput { - email: String! - password: String! + email: String! @binding(constraint: "required,email") + password: String! @binding(constraint: "required") } input RegisterInput { - username: String! - email: String! - password: String! - firstName: String! - lastName: String! + username: String! @binding(constraint: "required,min=3,max=50") + email: String! @binding(constraint: "required,email") + password: String! @binding(constraint: "required,min=8") + firstName: String! @binding(constraint: "required") + lastName: String! @binding(constraint: "required") } type AuthPayload { @@ -636,8 +654,8 @@ type AuthPayload { } input WorkInput { - name: String! - language: String! + name: String! @binding(constraint: "required,min=2") + language: String! @binding(constraint: "required,len=2") content: String authorIds: [ID!] tagIds: [ID!] @@ -645,15 +663,15 @@ input WorkInput { } input TranslationInput { - name: String! - language: String! + name: String! @binding(constraint: "required,min=2") + language: String! @binding(constraint: "required,len=2") content: String workId: ID! } input AuthorInput { - name: String! - language: String! + name: String! @binding(constraint: "required,min=2") + language: String! @binding(constraint: "required,len=2") biography: String birthDate: String deathDate: String @@ -711,3 +729,5 @@ input ContributionInput { translationId: ID status: ContributionStatus } + +directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index f0810ce..74c5580 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -11,11 +11,13 @@ import ( "tercul/internal/adapters/graphql/model" "tercul/internal/app/auth" "tercul/internal/app/author" + "tercul/internal/app/book" "tercul/internal/app/bookmark" "tercul/internal/app/collection" "tercul/internal/app/comment" "tercul/internal/app/like" "tercul/internal/app/translation" + "tercul/internal/app/user" "tercul/internal/domain" "tercul/internal/domain/work" platform_auth "tercul/internal/platform/auth" @@ -88,8 +90,8 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (* // 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) + if err := Validate(input); err != nil { + return nil, err } // Create domain model workModel := &work.Work{ @@ -131,12 +133,13 @@ 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) { - if err := validateWorkInput(input); err != nil { - return nil, fmt.Errorf("%w: %v", ErrValidation, err) + if err := Validate(input); err != nil { + return nil, err } + workID, err := strconv.ParseUint(id, 10, 32) if err != nil { - return nil, fmt.Errorf("invalid work ID: %v", err) + return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) } // Create domain model @@ -167,7 +170,7 @@ func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input mode func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, error) { workID, err := strconv.ParseUint(id, 10, 32) if err != nil { - return false, fmt.Errorf("invalid work ID: %v", err) + return false, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) } err = r.App.Work.Commands.DeleteWork(ctx, uint(workID)) @@ -180,12 +183,21 @@ func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, err // CreateTranslation is the resolver for the createTranslation field. func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) { - if err := validateTranslationInput(input); err != nil { - return nil, fmt.Errorf("%w: %v", ErrValidation, err) + if err := Validate(input); err != nil { + return nil, err } + + can, err := r.App.Authz.CanCreateTranslation(ctx) + if err != nil { + return nil, err + } + if !can { + return nil, domain.ErrForbidden + } + workID, err := strconv.ParseUint(input.WorkID, 10, 32) if err != nil { - return nil, fmt.Errorf("invalid work ID: %v", err) + return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) } // Create domain model @@ -227,8 +239,8 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr // UpdateTranslation is the resolver for the updateTranslation field. func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) { - if err := validateTranslationInput(input); err != nil { - return nil, fmt.Errorf("%w: %v", ErrValidation, err) + if err := Validate(input); err != nil { + return nil, err } translationID, err := strconv.ParseUint(id, 10, 32) if err != nil { @@ -274,10 +286,85 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo return true, nil } +// CreateBook is the resolver for the createBook field. +func (r *mutationResolver) CreateBook(ctx context.Context, input model.BookInput) (*model.Book, error) { + if err := Validate(input); err != nil { + return nil, err + } + + createInput := book.CreateBookInput{ + Title: input.Name, + Description: *input.Description, + Language: input.Language, + ISBN: input.Isbn, + } + + createdBook, err := r.App.Book.Commands.CreateBook(ctx, createInput) + if err != nil { + return nil, err + } + + return &model.Book{ + ID: fmt.Sprintf("%d", createdBook.ID), + Name: createdBook.Title, + Language: createdBook.Language, + Description: &createdBook.Description, + Isbn: &createdBook.ISBN, + }, nil +} + +// UpdateBook is the resolver for the updateBook field. +func (r *mutationResolver) UpdateBook(ctx context.Context, id string, input model.BookInput) (*model.Book, error) { + if err := Validate(input); err != nil { + return nil, err + } + + bookID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid book ID", domain.ErrValidation) + } + + updateInput := book.UpdateBookInput{ + ID: uint(bookID), + Title: &input.Name, + Description: input.Description, + Language: &input.Language, + ISBN: input.Isbn, + } + + updatedBook, err := r.App.Book.Commands.UpdateBook(ctx, updateInput) + if err != nil { + return nil, err + } + + return &model.Book{ + ID: id, + Name: updatedBook.Title, + Language: updatedBook.Language, + Description: &updatedBook.Description, + Isbn: &updatedBook.ISBN, + }, nil +} + +// DeleteBook is the resolver for the deleteBook field. +func (r *mutationResolver) DeleteBook(ctx context.Context, id string) (bool, error) { + bookID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("%w: invalid book ID", domain.ErrValidation) + } + + err = r.App.Book.Commands.DeleteBook(ctx, uint(bookID)) + 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) { - if err := validateAuthorInput(input); err != nil { - return nil, fmt.Errorf("%w: %v", ErrValidation, err) + if err := Validate(input); err != nil { + return nil, err } // Call author service createInput := author.CreateAuthorInput{ @@ -298,8 +385,8 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI // UpdateAuthor is the resolver for the updateAuthor field. func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) { - if err := validateAuthorInput(input); err != nil { - return nil, fmt.Errorf("%w: %v", ErrValidation, err) + if err := Validate(input); err != nil { + return nil, err } authorID, err := strconv.ParseUint(id, 10, 32) if err != nil { @@ -341,7 +428,78 @@ func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, e // UpdateUser is the resolver for the updateUser field. func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input model.UserInput) (*model.User, error) { - panic(fmt.Errorf("not implemented: UpdateUser - updateUser")) + if err := Validate(input); err != nil { + return nil, err + } + + userID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid user ID: %v", err) + } + + updateInput := user.UpdateUserInput{ + ID: uint(userID), + Username: input.Username, + Email: input.Email, + Password: input.Password, + FirstName: input.FirstName, + LastName: input.LastName, + DisplayName: input.DisplayName, + Bio: input.Bio, + AvatarURL: input.AvatarURL, + Verified: input.Verified, + Active: input.Active, + } + + if input.Role != nil { + role := domain.UserRole(input.Role.String()) + updateInput.Role = &role + } + + if input.CountryID != nil { + countryID, err := strconv.ParseUint(*input.CountryID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid country ID: %v", err) + } + uid := uint(countryID) + updateInput.CountryID = &uid + } + if input.CityID != nil { + cityID, err := strconv.ParseUint(*input.CityID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid city ID: %v", err) + } + uid := uint(cityID) + updateInput.CityID = &uid + } + if input.AddressID != nil { + addressID, err := strconv.ParseUint(*input.AddressID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid address ID: %v", err) + } + uid := uint(addressID) + updateInput.AddressID = &uid + } + + updatedUser, err := r.App.User.Commands.UpdateUser(ctx, updateInput) + if err != nil { + return nil, err + } + + // Convert to GraphQL model + return &model.User{ + ID: fmt.Sprintf("%d", updatedUser.ID), + Username: updatedUser.Username, + Email: updatedUser.Email, + FirstName: &updatedUser.FirstName, + LastName: &updatedUser.LastName, + DisplayName: &updatedUser.DisplayName, + Bio: &updatedUser.Bio, + AvatarURL: &updatedUser.AvatarURL, + Role: model.UserRole(updatedUser.Role), + Verified: updatedUser.Verified, + Active: updatedUser.Active, + }, nil } // DeleteUser is the resolver for the deleteUser field. @@ -990,6 +1148,51 @@ func (r *queryResolver) Translations(ctx context.Context, workID string, languag panic(fmt.Errorf("not implemented: Translations - translations")) } +// Book is the resolver for the book field. +func (r *queryResolver) Book(ctx context.Context, id string) (*model.Book, error) { + bookID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid book ID", domain.ErrValidation) + } + + bookRecord, err := r.App.Book.Queries.Book(ctx, uint(bookID)) + if err != nil { + return nil, err + } + if bookRecord == nil { + return nil, nil + } + + return &model.Book{ + ID: fmt.Sprintf("%d", bookRecord.ID), + Name: bookRecord.Title, + Language: bookRecord.Language, + Description: &bookRecord.Description, + Isbn: &bookRecord.ISBN, + }, nil +} + +// Books is the resolver for the books field. +func (r *queryResolver) Books(ctx context.Context, limit *int32, offset *int32) ([]*model.Book, error) { + books, err := r.App.Book.Queries.Books(ctx) + if err != nil { + return nil, err + } + + var result []*model.Book + for _, b := range books { + result = append(result, &model.Book{ + ID: fmt.Sprintf("%d", b.ID), + Name: b.Title, + Language: b.Language, + Description: &b.Description, + Isbn: &b.ISBN, + }) + } + + return result, nil +} + // Author is the resolver for the author field. func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, error) { panic(fmt.Errorf("not implemented: Author - author")) @@ -1264,63 +1467,3 @@ 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 *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.Analytics.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: toInt32(int64(stats.ReadingTime)), - Sentiment: &stats.Sentiment, - }, nil -} -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.Analytics.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: toInt32(int64(stats.ReadingTime)), - Complexity: &stats.Complexity, - Sentiment: &stats.Sentiment, - }, nil -} -func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} } -func (r *Resolver) Work() WorkResolver { return &workResolver{r} } -type translationResolver struct{ *Resolver } -type workResolver struct{ *Resolver } -*/ diff --git a/internal/adapters/graphql/validation.go b/internal/adapters/graphql/validation.go index c16f69c..0df3926 100644 --- a/internal/adapters/graphql/validation.go +++ b/internal/adapters/graphql/validation.go @@ -4,54 +4,30 @@ import ( "errors" "fmt" "strings" - "tercul/internal/adapters/graphql/model" + "tercul/internal/domain" - "github.com/asaskevich/govalidator" + "github.com/go-playground/validator/v10" ) -var ErrValidation = errors.New("validation failed") +// The 'validate' variable is declared in binding.go and is used here. -func validateWorkInput(input model.WorkInput) error { - name := strings.TrimSpace(input.Name) - if len(name) < 3 { - return fmt.Errorf("name must be at least 3 characters long") +// Validate performs validation on a struct using the validator library. +func Validate(s interface{}) error { + err := validate.Struct(s) + if err == nil { + return nil } - if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { - return fmt.Errorf("name can only contain letters, numbers, and spaces") - } - if len(input.Language) != 2 { - return fmt.Errorf("language must be a 2-character code") - } - return nil -} -func validateAuthorInput(input model.AuthorInput) error { - name := strings.TrimSpace(input.Name) - if len(name) < 3 { - return fmt.Errorf("name must be at least 3 characters long") + var validationErrors validator.ValidationErrors + if errors.As(err, &validationErrors) { + var errorMessages []string + for _, err := range validationErrors { + // Customize error messages here if needed. + errorMessages = append(errorMessages, fmt.Sprintf("field '%s' failed on the '%s' tag", err.Field(), err.Tag())) + } + return fmt.Errorf("%w: %s", domain.ErrValidation, strings.Join(errorMessages, "; ")) } - if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { - return fmt.Errorf("name can only contain letters, numbers, and spaces") - } - if len(input.Language) != 2 { - return fmt.Errorf("language must be a 2-character code") - } - return nil -} -func validateTranslationInput(input model.TranslationInput) error { - 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 -} + // For other unexpected errors, like invalid validation input. + return fmt.Errorf("unexpected error during validation: %w", err) +} \ No newline at end of file diff --git a/internal/adapters/graphql/work_repo_mock_test.go b/internal/adapters/graphql/work_repo_mock_test.go new file mode 100644 index 0000000..ac8cf9c --- /dev/null +++ b/internal/adapters/graphql/work_repo_mock_test.go @@ -0,0 +1,101 @@ +package graphql_test + +import ( + "context" + "tercul/internal/domain" + "tercul/internal/domain/work" + + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +// mockWorkRepository is a mock implementation of the WorkRepository interface. +type mockWorkRepository struct { + mock.Mock +} + +func (m *mockWorkRepository) Create(ctx context.Context, entity *work.Work) error { + args := m.Called(ctx, entity) + return args.Error(0) +} +func (m *mockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { + return m.Create(ctx, entity) +} +func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*work.Work), args.Error(1) +} +func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { + return m.GetByID(ctx, id) +} +func (m *mockWorkRepository) Update(ctx context.Context, entity *work.Work) error { + args := m.Called(ctx, entity) + return args.Error(0) +} +func (m *mockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { + return m.Update(ctx, entity) +} +func (m *mockWorkRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} +func (m *mockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + return m.Delete(ctx, id) +} +func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + panic("not implemented") +} +func (m *mockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { + panic("not implemented") +} +func (m *mockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { panic("not implemented") } +func (m *mockWorkRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} +func (m *mockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + panic("not implemented") +} +func (m *mockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) { + return m.GetByID(ctx, id) +} +func (m *mockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) { + panic("not implemented") +} +func (m *mockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} +func (m *mockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } +func (m *mockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} +func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { + panic("not implemented") +} +func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { + panic("not implemented") +} +func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { + panic("not implemented") +} +func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + panic("not implemented") +} +func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*work.Work), args.Error(1) +} +func (m *mockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + panic("not implemented") +} +func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { + args := m.Called(ctx, workID, authorID) + return args.Bool(0), args.Error(1) +} \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 623102d..1061521 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "tercul/internal/app/analytics" "tercul/internal/app/author" + "tercul/internal/app/book" "tercul/internal/app/bookmark" "tercul/internal/app/category" "tercul/internal/app/collection" @@ -19,9 +20,12 @@ import ( platform_auth "tercul/internal/platform/auth" ) +import "tercul/internal/app/authz" + // Application is a container for all the application-layer services. type Application struct { Author *author.Service + Book *book.Service Bookmark *bookmark.Service Category *category.Service Collection *collection.Service @@ -32,27 +36,31 @@ type Application struct { User *user.Service Localization *localization.Service Auth *auth.Service + Authz *authz.Service Work *work.Service Analytics analytics.Service } func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application { jwtManager := platform_auth.NewJWTManager() + authzService := authz.NewService(repos.Work, repos.Translation) authorService := author.NewService(repos.Author) + bookService := book.NewService(repos.Book, authzService) bookmarkService := bookmark.NewService(repos.Bookmark) categoryService := category.NewService(repos.Category) collectionService := collection.NewService(repos.Collection) - commentService := comment.NewService(repos.Comment) + commentService := comment.NewService(repos.Comment, authzService) likeService := like.NewService(repos.Like) tagService := tag.NewService(repos.Tag) - translationService := translation.NewService(repos.Translation) - userService := user.NewService(repos.User) + translationService := translation.NewService(repos.Translation, authzService) + userService := user.NewService(repos.User, authzService) localizationService := localization.NewService(repos.Localization) authService := auth.NewService(repos.User, jwtManager) - workService := work.NewService(repos.Work, searchClient) + workService := work.NewService(repos.Work, searchClient, authzService) return &Application{ Author: authorService, + Book: bookService, Bookmark: bookmarkService, Category: categoryService, Collection: collectionService, @@ -63,6 +71,7 @@ func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, a User: userService, Localization: localizationService, Auth: authService, + Authz: authzService, Work: workService, Analytics: analyticsService, } diff --git a/internal/app/authz/authz.go b/internal/app/authz/authz.go new file mode 100644 index 0000000..1eb5a87 --- /dev/null +++ b/internal/app/authz/authz.go @@ -0,0 +1,188 @@ +package authz + +import ( + "context" + "tercul/internal/domain" + "tercul/internal/domain/work" + platform_auth "tercul/internal/platform/auth" +) + +// Service provides authorization checks for the application. +type Service struct { + workRepo work.WorkRepository + translationRepo domain.TranslationRepository +} + +// NewService creates a new authorization service. +func NewService(workRepo work.WorkRepository, translationRepo domain.TranslationRepository) *Service { + return &Service{ + workRepo: workRepo, + translationRepo: translationRepo, + } +} + +// CanEditWork checks if a user has permission to edit a work. +// For now, we'll implement a simple rule: only an admin or the work's author can edit it. +func (s *Service) CanEditWork(ctx context.Context, userID uint, work *work.Work) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + + // Admins can do anything. + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + + // Check if the user is an author of the work. + isAuthor, err := s.workRepo.IsAuthor(ctx, work.ID, userID) + if err != nil { + return false, err + } + if isAuthor { + return true, nil + } + + return false, domain.ErrForbidden +} + +// CanDeleteWork checks if a user has permission to delete a work. +func (s *Service) CanDeleteWork(ctx context.Context) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + return false, domain.ErrForbidden +} + +// CanDeleteTranslation checks if a user can delete a translation. +func (s *Service) CanDeleteTranslation(ctx context.Context) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + + // Admins can do anything. + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + + return false, domain.ErrForbidden +} + +// CanUpdateUser checks if a user has permission to update another user's profile. +func (s *Service) CanCreateWork(ctx context.Context) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + return false, domain.ErrForbidden +} + +func (s *Service) CanCreateTranslation(ctx context.Context) (bool, error) { + _, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + return true, nil +} + +func (s *Service) CanEditTranslation(ctx context.Context, userID uint, translationID uint) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + + // Admins can do anything. + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + + // Check if the user is the translator of the translation. + translation, err := s.translationRepo.GetByID(ctx, translationID) + if err != nil { + return false, err + } + + if translation.TranslatorID != nil && *translation.TranslatorID == userID { + return true, nil + } + + return false, domain.ErrForbidden +} + +func (s *Service) CanCreateBook(ctx context.Context) (bool, error) { + _, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + return true, nil +} + +func (s *Service) CanUpdateBook(ctx context.Context) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + return false, domain.ErrForbidden +} + +func (s *Service) CanDeleteBook(ctx context.Context) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + return false, domain.ErrForbidden +} + +func (s *Service) CanUpdateUser(ctx context.Context, actorID, targetUserID uint) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + + // Admins can do anything. + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + + // Users can update their own profile. + if actorID == targetUserID { + return true, nil + } + + return false, domain.ErrForbidden +} + +// CanDeleteComment checks if a user has permission to delete a comment. +// For now, we'll implement a simple rule: only an admin or the comment's author can delete it. +func (s *Service) CanDeleteComment(ctx context.Context, userID uint, comment *domain.Comment) (bool, error) { + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + + // Admins can do anything. + if claims.Role == string(domain.UserRoleAdmin) { + return true, nil + } + + // Check if the user is the author of the comment. + if comment.UserID == userID { + return true, nil + } + + return false, domain.ErrForbidden +} \ No newline at end of file diff --git a/internal/app/book/commands.go b/internal/app/book/commands.go new file mode 100644 index 0000000..a86fbaa --- /dev/null +++ b/internal/app/book/commands.go @@ -0,0 +1,118 @@ +package book + +import ( + "context" + "tercul/internal/app/authz" + "tercul/internal/domain" +) + +// BookCommands contains the command handlers for the book aggregate. +type BookCommands struct { + repo domain.BookRepository + authzSvc *authz.Service +} + +// NewBookCommands creates a new BookCommands handler. +func NewBookCommands(repo domain.BookRepository, authzSvc *authz.Service) *BookCommands { + return &BookCommands{ + repo: repo, + authzSvc: authzSvc, + } +} + +// CreateBookInput represents the input for creating a new book. +type CreateBookInput struct { + Title string + Description string + Language string + ISBN *string + AuthorIDs []uint +} + +// CreateBook creates a new book. +func (c *BookCommands) CreateBook(ctx context.Context, input CreateBookInput) (*domain.Book, error) { + can, err := c.authzSvc.CanCreateBook(ctx) + if err != nil { + return nil, err + } + if !can { + return nil, domain.ErrForbidden + } + + book := &domain.Book{ + Title: input.Title, + Description: input.Description, + TranslatableModel: domain.TranslatableModel{ + Language: input.Language, + }, + } + if input.ISBN != nil { + book.ISBN = *input.ISBN + } + + // In a real implementation, we would associate the authors here. + // for _, authorID := range input.AuthorIDs { ... } + + err = c.repo.Create(ctx, book) + if err != nil { + return nil, err + } + return book, nil +} + +// UpdateBookInput represents the input for updating an existing book. +type UpdateBookInput struct { + ID uint + Title *string + Description *string + Language *string + ISBN *string + AuthorIDs []uint +} + +// UpdateBook updates an existing book. +func (c *BookCommands) UpdateBook(ctx context.Context, input UpdateBookInput) (*domain.Book, error) { + can, err := c.authzSvc.CanUpdateBook(ctx) + if err != nil { + return nil, err + } + if !can { + return nil, domain.ErrForbidden + } + + book, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + + if input.Title != nil { + book.Title = *input.Title + } + if input.Description != nil { + book.Description = *input.Description + } + if input.Language != nil { + book.Language = *input.Language + } + if input.ISBN != nil { + book.ISBN = *input.ISBN + } + + err = c.repo.Update(ctx, book) + if err != nil { + return nil, err + } + return book, nil +} + +// DeleteBook deletes a book by ID. +func (c *BookCommands) DeleteBook(ctx context.Context, id uint) error { + can, err := c.authzSvc.CanDeleteBook(ctx) + if err != nil { + return err + } + if !can { + return domain.ErrForbidden + } + return c.repo.Delete(ctx, id) +} \ No newline at end of file diff --git a/internal/app/book/queries.go b/internal/app/book/queries.go new file mode 100644 index 0000000..5abd3b9 --- /dev/null +++ b/internal/app/book/queries.go @@ -0,0 +1,26 @@ +package book + +import ( + "context" + "tercul/internal/domain" +) + +// BookQueries contains the query handlers for the book aggregate. +type BookQueries struct { + repo domain.BookRepository +} + +// NewBookQueries creates a new BookQueries handler. +func NewBookQueries(repo domain.BookRepository) *BookQueries { + return &BookQueries{repo: repo} +} + +// Book retrieves a book by its ID. +func (q *BookQueries) Book(ctx context.Context, id uint) (*domain.Book, error) { + return q.repo.GetByID(ctx, id) +} + +// Books retrieves a list of all books. +func (q *BookQueries) Books(ctx context.Context) ([]domain.Book, error) { + return q.repo.ListAll(ctx) +} \ No newline at end of file diff --git a/internal/app/book/service.go b/internal/app/book/service.go new file mode 100644 index 0000000..ce4bea4 --- /dev/null +++ b/internal/app/book/service.go @@ -0,0 +1,20 @@ +package book + +import ( + "tercul/internal/app/authz" + "tercul/internal/domain" +) + +// Service is the application service for the book aggregate. +type Service struct { + Commands *BookCommands + Queries *BookQueries +} + +// NewService creates a new book Service. +func NewService(repo domain.BookRepository, authzSvc *authz.Service) *Service { + return &Service{ + Commands: NewBookCommands(repo, authzSvc), + Queries: NewBookQueries(repo), + } +} \ No newline at end of file diff --git a/internal/app/comment/commands.go b/internal/app/comment/commands.go index 82e13e0..21e827d 100644 --- a/internal/app/comment/commands.go +++ b/internal/app/comment/commands.go @@ -2,17 +2,27 @@ package comment import ( "context" + "errors" + "fmt" + "tercul/internal/app/authz" "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" + + "gorm.io/gorm" ) // CommentCommands contains the command handlers for the comment aggregate. type CommentCommands struct { - repo domain.CommentRepository + repo domain.CommentRepository + authzSvc *authz.Service } // NewCommentCommands creates a new CommentCommands handler. -func NewCommentCommands(repo domain.CommentRepository) *CommentCommands { - return &CommentCommands{repo: repo} +func NewCommentCommands(repo domain.CommentRepository, authzSvc *authz.Service) *CommentCommands { + return &CommentCommands{ + repo: repo, + authzSvc: authzSvc, + } } // CreateCommentInput represents the input for creating a new comment. @@ -46,12 +56,29 @@ type UpdateCommentInput struct { Text string } -// UpdateComment updates an existing comment. +// UpdateComment updates an existing comment after an authorization check. func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) { + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + comment, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, input.ID) + } + return nil, err + } + + can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment) // Using CanDeleteComment for editing as well if err != nil { return nil, err } + if !can { + return nil, domain.ErrForbidden + } + comment.Text = input.Text err = c.repo.Update(ctx, comment) if err != nil { @@ -60,7 +87,28 @@ func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateComment return comment, nil } -// DeleteComment deletes a comment by ID. +// DeleteComment deletes a comment by ID after an authorization check. func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error { + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return domain.ErrUnauthorized + } + + comment, err := c.repo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, id) + } + return err + } + + can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment) + if err != nil { + return err + } + if !can { + return domain.ErrForbidden + } + return c.repo.Delete(ctx, id) -} +} \ No newline at end of file diff --git a/internal/app/comment/service.go b/internal/app/comment/service.go index 23c449f..32eb34c 100644 --- a/internal/app/comment/service.go +++ b/internal/app/comment/service.go @@ -1,6 +1,9 @@ package comment -import "tercul/internal/domain" +import ( + "tercul/internal/app/authz" + "tercul/internal/domain" +) // Service is the application service for the comment aggregate. type Service struct { @@ -9,9 +12,9 @@ type Service struct { } // NewService creates a new comment Service. -func NewService(repo domain.CommentRepository) *Service { +func NewService(repo domain.CommentRepository, authzSvc *authz.Service) *Service { return &Service{ - Commands: NewCommentCommands(repo), + Commands: NewCommentCommands(repo, authzSvc), Queries: NewCommentQueries(repo), } } diff --git a/internal/app/translation/commands.go b/internal/app/translation/commands.go index 9d07247..4656e11 100644 --- a/internal/app/translation/commands.go +++ b/internal/app/translation/commands.go @@ -2,17 +2,27 @@ package translation import ( "context" + "errors" + "fmt" + "tercul/internal/app/authz" "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" + + "gorm.io/gorm" ) // TranslationCommands contains the command handlers for the translation aggregate. type TranslationCommands struct { - repo domain.TranslationRepository + repo domain.TranslationRepository + authzSvc *authz.Service } // NewTranslationCommands creates a new TranslationCommands handler. -func NewTranslationCommands(repo domain.TranslationRepository) *TranslationCommands { - return &TranslationCommands{repo: repo} +func NewTranslationCommands(repo domain.TranslationRepository, authzSvc *authz.Service) *TranslationCommands { + return &TranslationCommands{ + repo: repo, + authzSvc: authzSvc, + } } // CreateTranslationInput represents the input for creating a new translation. @@ -60,10 +70,27 @@ type UpdateTranslationInput struct { // 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) + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + + can, err := c.authzSvc.CanEditTranslation(ctx, userID, input.ID) if err != nil { return nil, err } + if !can { + return nil, domain.ErrForbidden + } + + translation, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: translation with id %d not found", domain.ErrNotFound, input.ID) + } + return nil, err + } + translation.Title = input.Title translation.Content = input.Content translation.Description = input.Description @@ -78,5 +105,13 @@ func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input Updat // DeleteTranslation deletes a translation by ID. func (c *TranslationCommands) DeleteTranslation(ctx context.Context, id uint) error { + can, err := c.authzSvc.CanDeleteTranslation(ctx) + if err != nil { + return err + } + if !can { + return domain.ErrForbidden + } + return c.repo.Delete(ctx, id) } diff --git a/internal/app/translation/service.go b/internal/app/translation/service.go index 5183a9c..4f966e5 100644 --- a/internal/app/translation/service.go +++ b/internal/app/translation/service.go @@ -1,6 +1,9 @@ package translation -import "tercul/internal/domain" +import ( + "tercul/internal/app/authz" + "tercul/internal/domain" +) // Service is the application service for the translation aggregate. type Service struct { @@ -9,9 +12,9 @@ type Service struct { } // NewService creates a new translation Service. -func NewService(repo domain.TranslationRepository) *Service { +func NewService(repo domain.TranslationRepository, authzSvc *authz.Service) *Service { return &Service{ - Commands: NewTranslationCommands(repo), + Commands: NewTranslationCommands(repo, authzSvc), Queries: NewTranslationQueries(repo), } } diff --git a/internal/app/user/commands.go b/internal/app/user/commands.go index 87f5232..4e91d87 100644 --- a/internal/app/user/commands.go +++ b/internal/app/user/commands.go @@ -2,17 +2,27 @@ package user import ( "context" + "errors" + "fmt" + "tercul/internal/app/authz" "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" + + "gorm.io/gorm" ) // UserCommands contains the command handlers for the user aggregate. type UserCommands struct { - repo domain.UserRepository + repo domain.UserRepository + authzSvc *authz.Service } // NewUserCommands creates a new UserCommands handler. -func NewUserCommands(repo domain.UserRepository) *UserCommands { - return &UserCommands{repo: repo} +func NewUserCommands(repo domain.UserRepository, authzSvc *authz.Service) *UserCommands { + return &UserCommands{ + repo: repo, + authzSvc: authzSvc, + } } // CreateUserInput represents the input for creating a new user. @@ -44,25 +54,92 @@ func (c *UserCommands) CreateUser(ctx context.Context, input CreateUserInput) (* // UpdateUserInput represents the input for updating an existing user. type UpdateUserInput struct { - ID uint - Username string - Email string - FirstName string - LastName string - Role domain.UserRole + ID uint + Username *string + Email *string + Password *string + FirstName *string + LastName *string + DisplayName *string + Bio *string + AvatarURL *string + Role *domain.UserRole + Verified *bool + Active *bool + CountryID *uint + CityID *uint + AddressID *uint } // UpdateUser updates an existing user. func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*domain.User, error) { - user, err := c.repo.GetByID(ctx, input.ID) + actorID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + + can, err := c.authzSvc.CanUpdateUser(ctx, actorID, input.ID) if err != nil { return nil, err } - user.Username = input.Username - user.Email = input.Email - user.FirstName = input.FirstName - user.LastName = input.LastName - user.Role = input.Role + if !can { + return nil, domain.ErrForbidden + } + + user, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: user with id %d not found", domain.ErrNotFound, input.ID) + } + return nil, err + } + + // Apply partial updates + if input.Username != nil { + user.Username = *input.Username + } + if input.Email != nil { + user.Email = *input.Email + } + if input.Password != nil { + if err := user.SetPassword(*input.Password); err != nil { + return nil, err + } + } + if input.FirstName != nil { + user.FirstName = *input.FirstName + } + if input.LastName != nil { + user.LastName = *input.LastName + } + if input.DisplayName != nil { + user.DisplayName = *input.DisplayName + } + if input.Bio != nil { + user.Bio = *input.Bio + } + if input.AvatarURL != nil { + user.AvatarURL = *input.AvatarURL + } + if input.Role != nil { + user.Role = *input.Role + } + if input.Verified != nil { + user.Verified = *input.Verified + } + if input.Active != nil { + user.Active = *input.Active + } + if input.CountryID != nil { + user.CountryID = input.CountryID + } + if input.CityID != nil { + user.CityID = input.CityID + } + if input.AddressID != nil { + user.AddressID = input.AddressID + } + err = c.repo.Update(ctx, user) if err != nil { return nil, err @@ -72,5 +149,18 @@ func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (* // DeleteUser deletes a user by ID. func (c *UserCommands) DeleteUser(ctx context.Context, id uint) error { + actorID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return domain.ErrUnauthorized + } + + can, err := c.authzSvc.CanUpdateUser(ctx, actorID, id) // Re-using CanUpdateUser for deletion + if err != nil { + return err + } + if !can { + return domain.ErrForbidden + } + return c.repo.Delete(ctx, id) -} +} \ No newline at end of file diff --git a/internal/app/user/commands_test.go b/internal/app/user/commands_test.go new file mode 100644 index 0000000..e1649f7 --- /dev/null +++ b/internal/app/user/commands_test.go @@ -0,0 +1,102 @@ +package user + +import ( + "context" + "testing" + + "tercul/internal/app/authz" + "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type UserCommandsSuite struct { + suite.Suite + repo *mockUserRepository + authzSvc *authz.Service + commands *UserCommands +} + +func (s *UserCommandsSuite) SetupTest() { + s.repo = &mockUserRepository{} + workRepo := &mockWorkRepoForUserTests{} + s.authzSvc = authz.NewService(workRepo, nil) // Translation repo not needed for user tests + s.commands = NewUserCommands(s.repo, s.authzSvc) +} + +func TestUserCommandsSuite(t *testing.T) { + suite.Run(t, new(UserCommandsSuite)) +} + +func (s *UserCommandsSuite) TestUpdateUser_Success_Self() { + // Arrange + ctx := platform_auth.ContextWithUserID(context.Background(), 1) + input := UpdateUserInput{ID: 1, Username: strPtr("new_username")} + + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.User, error) { + return &domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil + } + + // Act + updatedUser, err := s.commands.UpdateUser(ctx, input) + + // Assert + assert.NoError(s.T(), err) + assert.NotNil(s.T(), updatedUser) + assert.Equal(s.T(), "new_username", updatedUser.Username) +} + +func (s *UserCommandsSuite) TestUpdateUser_Success_Admin() { + // Arrange + ctx := platform_auth.ContextWithAdminUser(context.Background(), 99) // Admin user + input := UpdateUserInput{ID: 1, Username: strPtr("new_username_by_admin")} + + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.User, error) { + return &domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil + } + + // Act + updatedUser, err := s.commands.UpdateUser(ctx, input) + + // Assert + assert.NoError(s.T(), err) + assert.NotNil(s.T(), updatedUser) + assert.Equal(s.T(), "new_username_by_admin", updatedUser.Username) +} + +func (s *UserCommandsSuite) TestUpdateUser_Forbidden() { + // Arrange + ctx := platform_auth.ContextWithUserID(context.Background(), 2) // Different user + input := UpdateUserInput{ID: 1, Username: strPtr("forbidden_username")} + + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.User, error) { + return &domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil + } + + // Act + _, err := s.commands.UpdateUser(ctx, input) + + // Assert + assert.Error(s.T(), err) + assert.ErrorIs(s.T(), err, domain.ErrForbidden) +} + +func (s *UserCommandsSuite) TestUpdateUser_Unauthorized() { + // Arrange + ctx := context.Background() // No user in context + input := UpdateUserInput{ID: 1, Username: strPtr("unauthorized_username")} + + // Act + _, err := s.commands.UpdateUser(ctx, input) + + // Assert + assert.Error(s.T(), err) + assert.ErrorIs(s.T(), err, domain.ErrUnauthorized) +} + +// Helper to get a pointer to a string +func strPtr(s string) *string { + return &s +} \ No newline at end of file diff --git a/internal/app/user/main_test.go b/internal/app/user/main_test.go new file mode 100644 index 0000000..9322f61 --- /dev/null +++ b/internal/app/user/main_test.go @@ -0,0 +1,32 @@ +package user + +import ( + "context" + "tercul/internal/domain" +) + +type mockUserRepository struct { + domain.UserRepository + createFunc func(ctx context.Context, user *domain.User) error + updateFunc func(ctx context.Context, user *domain.User) error + getByIDFunc func(ctx context.Context, id uint) (*domain.User, error) +} + +func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error { + if m.createFunc != nil { + return m.createFunc(ctx, user) + } + return nil +} +func (m *mockUserRepository) Update(ctx context.Context, user *domain.User) error { + if m.updateFunc != nil { + return m.updateFunc(ctx, user) + } + return nil +} +func (m *mockUserRepository) GetByID(ctx context.Context, id uint) (*domain.User, error) { + if m.getByIDFunc != nil { + return m.getByIDFunc(ctx, id) + } + return &domain.User{BaseModel: domain.BaseModel{ID: id}}, nil +} \ No newline at end of file diff --git a/internal/app/user/service.go b/internal/app/user/service.go index 40e45a5..c8a277c 100644 --- a/internal/app/user/service.go +++ b/internal/app/user/service.go @@ -1,6 +1,9 @@ package user -import "tercul/internal/domain" +import ( + "tercul/internal/app/authz" + "tercul/internal/domain" +) // Service is the application service for the user aggregate. type Service struct { @@ -9,9 +12,9 @@ type Service struct { } // NewService creates a new user Service. -func NewService(repo domain.UserRepository) *Service { +func NewService(repo domain.UserRepository, authzSvc *authz.Service) *Service { return &Service{ - Commands: NewUserCommands(repo), + Commands: NewUserCommands(repo, authzSvc), Queries: NewUserQueries(repo), } } diff --git a/internal/app/user/work_repo_mock_test.go b/internal/app/user/work_repo_mock_test.go new file mode 100644 index 0000000..830937c --- /dev/null +++ b/internal/app/user/work_repo_mock_test.go @@ -0,0 +1,71 @@ +package user + +import ( + "context" + "tercul/internal/domain" + "tercul/internal/domain/work" + + "gorm.io/gorm" +) + +type mockWorkRepoForUserTests struct{} + +func (m *mockWorkRepoForUserTests) Create(ctx context.Context, entity *work.Work) error { return nil } +func (m *mockWorkRepoForUserTests) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { + return nil +} +func (m *mockWorkRepoForUserTests) GetByID(ctx context.Context, id uint) (*work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) Update(ctx context.Context, entity *work.Work) error { return nil } +func (m *mockWorkRepoForUserTests) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { + return nil +} +func (m *mockWorkRepoForUserTests) Delete(ctx context.Context, id uint) error { return nil } +func (m *mockWorkRepoForUserTests) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil } +func (m *mockWorkRepoForUserTests) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) ListAll(ctx context.Context) ([]work.Work, error) { return nil, nil } +func (m *mockWorkRepoForUserTests) Count(ctx context.Context) (int64, error) { return 0, nil } +func (m *mockWorkRepoForUserTests) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + return 0, nil +} +func (m *mockWorkRepoForUserTests) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } +func (m *mockWorkRepoForUserTests) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } +func (m *mockWorkRepoForUserTests) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} +func (m *mockWorkRepoForUserTests) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + return nil, nil +} +func (m *mockWorkRepoForUserTests) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { + return false, nil +} \ No newline at end of file diff --git a/internal/app/work/commands.go b/internal/app/work/commands.go index e9f0616..b1c67a3 100644 --- a/internal/app/work/commands.go +++ b/internal/app/work/commands.go @@ -3,21 +3,29 @@ package work import ( "context" "errors" + "fmt" + "tercul/internal/app/authz" + "tercul/internal/domain" "tercul/internal/domain/search" "tercul/internal/domain/work" + platform_auth "tercul/internal/platform/auth" + + "gorm.io/gorm" ) // WorkCommands contains the command handlers for the work aggregate. type WorkCommands struct { repo work.WorkRepository searchClient search.SearchClient + authzSvc *authz.Service } // NewWorkCommands creates a new WorkCommands handler. -func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient) *WorkCommands { +func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *WorkCommands { return &WorkCommands{ repo: repo, searchClient: searchClient, + authzSvc: authzSvc, } } @@ -44,21 +52,44 @@ func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.W return work, nil } -// UpdateWork updates an existing work. +// UpdateWork updates an existing work after performing an authorization check. func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error { if work == nil { - return errors.New("work cannot be nil") + return fmt.Errorf("%w: work cannot be nil", domain.ErrValidation) } if work.ID == 0 { - return errors.New("work ID cannot be zero") + return fmt.Errorf("%w: work ID cannot be zero", domain.ErrValidation) } + + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return domain.ErrUnauthorized + } + + existingWork, err := c.repo.GetByID(ctx, work.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, work.ID) + } + return fmt.Errorf("failed to get work for authorization: %w", err) + } + + can, err := c.authzSvc.CanEditWork(ctx, userID, existingWork) + if err != nil { + return err + } + if !can { + return domain.ErrForbidden + } + if work.Title == "" { - return errors.New("work title cannot be empty") + return fmt.Errorf("%w: work title cannot be empty", domain.ErrValidation) } if work.Language == "" { - return errors.New("work language cannot be empty") + return fmt.Errorf("%w: work language cannot be empty", domain.ErrValidation) } - err := c.repo.Update(ctx, work) + + err = c.repo.Update(ctx, work) if err != nil { return err } @@ -66,11 +97,36 @@ func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error { return c.searchClient.IndexWork(ctx, work, "") } -// DeleteWork deletes a work by ID. +// DeleteWork deletes a work by ID after performing an authorization check. func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error { if id == 0 { - return errors.New("invalid work ID") + return fmt.Errorf("%w: invalid work ID", domain.ErrValidation) } + + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return domain.ErrUnauthorized + } + + existingWork, err := c.repo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, id) + } + return fmt.Errorf("failed to get work for authorization: %w", err) + } + + can, err := c.authzSvc.CanDeleteWork(ctx) + if err != nil { + return err + } + if !can { + return domain.ErrForbidden + } + + _ = userID // to avoid unused variable error + _ = existingWork // to avoid unused variable error + return c.repo.Delete(ctx, id) } diff --git a/internal/app/work/commands_test.go b/internal/app/work/commands_test.go index 6a0d0b6..50fa5ec 100644 --- a/internal/app/work/commands_test.go +++ b/internal/app/work/commands_test.go @@ -5,8 +5,10 @@ import ( "errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "tercul/internal/app/authz" "tercul/internal/domain" workdomain "tercul/internal/domain/work" + platform_auth "tercul/internal/platform/auth" "testing" ) @@ -14,13 +16,15 @@ type WorkCommandsSuite struct { suite.Suite repo *mockWorkRepository searchClient *mockSearchClient + authzSvc *authz.Service commands *WorkCommands } func (s *WorkCommandsSuite) SetupTest() { s.repo = &mockWorkRepository{} s.searchClient = &mockSearchClient{} - s.commands = NewWorkCommands(s.repo, s.searchClient) + s.authzSvc = authz.NewService(s.repo, nil) + s.commands = NewWorkCommands(s.repo, s.searchClient, s.authzSvc) } func TestWorkCommandsSuite(t *testing.T) { @@ -60,9 +64,18 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() { } func (s *WorkCommandsSuite) TestUpdateWork_Success() { + ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 - err := s.commands.UpdateWork(context.Background(), work) + + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) { + return work, nil + } + s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) { + return true, nil + } + + err := s.commands.UpdateWork(ctx, work) assert.NoError(s.T(), err) } @@ -102,7 +115,18 @@ func (s *WorkCommandsSuite) TestUpdateWork_RepoError() { } func (s *WorkCommandsSuite) TestDeleteWork_Success() { - err := s.commands.DeleteWork(context.Background(), 1) + ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) + work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work.ID = 1 + + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) { + return work, nil + } + s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) { + return true, nil + } + + err := s.commands.DeleteWork(ctx, 1) assert.NoError(s.T(), err) } diff --git a/internal/app/work/main_test.go b/internal/app/work/main_test.go index a913041..0581967 100644 --- a/internal/app/work/main_test.go +++ b/internal/app/work/main_test.go @@ -18,6 +18,14 @@ type mockWorkRepository struct { findByAuthorFunc func(ctx context.Context, authorID uint) ([]work.Work, error) findByCategoryFunc func(ctx context.Context, categoryID uint) ([]work.Work, error) findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) + isAuthorFunc func(ctx context.Context, workID uint, authorID uint) (bool, error) +} + +func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { + if m.isAuthorFunc != nil { + return m.isAuthorFunc(ctx, workID, authorID) + } + return false, nil } func (m *mockWorkRepository) Create(ctx context.Context, work *work.Work) error { @@ -42,7 +50,7 @@ func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, if m.getByIDFunc != nil { return m.getByIDFunc(ctx, id) } - return nil, nil + return &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}, nil } func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { if m.listFunc != nil { diff --git a/internal/app/work/service.go b/internal/app/work/service.go index 0c8f8eb..9a1317b 100644 --- a/internal/app/work/service.go +++ b/internal/app/work/service.go @@ -1,6 +1,7 @@ package work import ( + "tercul/internal/app/authz" "tercul/internal/domain/search" "tercul/internal/domain/work" ) @@ -12,9 +13,9 @@ type Service struct { } // NewService creates a new work Service. -func NewService(repo work.WorkRepository, searchClient search.SearchClient) *Service { +func NewService(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *Service { return &Service{ - Commands: NewWorkCommands(repo, searchClient), + Commands: NewWorkCommands(repo, searchClient, authzSvc), Queries: NewWorkQueries(repo), } } diff --git a/internal/data/sql/work_repository.go b/internal/data/sql/work_repository.go index 3797608..e23e92a 100644 --- a/internal/data/sql/work_repository.go +++ b/internal/data/sql/work_repository.go @@ -120,6 +120,21 @@ func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*wor return r.FindWithPreload(ctx, []string{"Translations"}, id) } +// IsAuthor checks if a user is an author of a work. +// Note: This assumes a direct relationship between user ID and author ID, +// which may need to be revised based on the actual domain model. +func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { + var count int64 + err := r.db.WithContext(ctx). + Table("work_authors"). + Where("work_id = ? AND author_id = ?", workID, authorID). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + // ListWithTranslations lists works with their translations func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { if page < 1 { diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..be9ef8a --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,20 @@ +package domain + +import "errors" + +var ( + // ErrNotFound indicates that a requested resource was not found. + ErrNotFound = errors.New("not found") + + // ErrUnauthorized indicates that the user is not authenticated. + ErrUnauthorized = errors.New("unauthorized") + + // ErrForbidden indicates that the user is authenticated but not authorized to perform the action. + ErrForbidden = errors.New("forbidden") + + // ErrValidation indicates that the input failed validation. + ErrValidation = errors.New("validation failed") + + // ErrConflict indicates a conflict with the current state of the resource (e.g., duplicate). + ErrConflict = errors.New("conflict") +) \ No newline at end of file diff --git a/internal/domain/work/repo.go b/internal/domain/work/repo.go index 114e78e..bc040b0 100644 --- a/internal/domain/work/repo.go +++ b/internal/domain/work/repo.go @@ -14,4 +14,5 @@ type WorkRepository interface { FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error) GetWithTranslations(ctx context.Context, id uint) (*Work, error) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error) + IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) } \ No newline at end of file diff --git a/internal/observability/logger.go b/internal/observability/logger.go new file mode 100644 index 0000000..76df103 --- /dev/null +++ b/internal/observability/logger.go @@ -0,0 +1,54 @@ +package observability + +import ( + "context" + "os" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/trace" +) + +// Logger is a wrapper around zerolog.Logger to provide a consistent logging interface. +type Logger struct { + *zerolog.Logger +} + +// NewLogger creates a new Logger instance. +// It writes to a human-friendly console in "development" environment, +// and writes JSON to stdout otherwise. +func NewLogger(serviceName, environment string) *Logger { + var logger zerolog.Logger + if environment == "development" { + logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With(). + Timestamp(). + Str("service", serviceName). + Logger() + } else { + zerolog.TimeFieldFormat = time.RFC3339 + logger = zerolog.New(os.Stdout).With(). + Timestamp(). + Str("service", serviceName). + Logger() + } + + return &Logger{&logger} +} + +// Ctx returns a new logger with context-specific fields, such as trace and span IDs. +func (l *Logger) Ctx(ctx context.Context) *Logger { + log := l.Logger // log is a *zerolog.Logger + span := trace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + // .Logger() returns a value, not a pointer. + // We create a new logger value... + newLogger := log.With(). + Str("trace_id", span.SpanContext().TraceID().String()). + Str("span_id", span.SpanContext().SpanID().String()). + Logger() + // ...and then use its address. + log = &newLogger + } + // `log` is now the correct *zerolog.Logger, so we wrap it. + return &Logger{log} +} \ No newline at end of file diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go new file mode 100644 index 0000000..f6892f0 --- /dev/null +++ b/internal/observability/metrics.go @@ -0,0 +1,55 @@ +package observability + +import ( + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Metrics contains the Prometheus metrics for the application. +type Metrics struct { + RequestsTotal *prometheus.CounterVec + RequestDuration *prometheus.HistogramVec +} + +// NewMetrics creates and registers the Prometheus metrics. +func NewMetrics(reg prometheus.Registerer) *Metrics { + return &Metrics{ + RequestsTotal: promauto.With(reg).NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests.", + }, + []string{"method", "path", "status"}, + ), + RequestDuration: promauto.With(reg).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "Duration of HTTP requests.", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "path"}, + ), + } +} + +// PrometheusMiddleware returns an HTTP middleware that records Prometheus metrics. +func (m *Metrics) PrometheusMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &responseWriter{ResponseWriter: w} + next.ServeHTTP(rw, r) + + duration := time.Since(start).Seconds() + m.RequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration) + m.RequestsTotal.WithLabelValues(r.Method, r.URL.Path, http.StatusText(rw.statusCode)).Inc() + }) +} + +// PrometheusHandler returns an HTTP handler for serving Prometheus metrics. +func PrometheusHandler(reg prometheus.Gatherer) http.Handler { + return promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) +} diff --git a/internal/observability/middleware.go b/internal/observability/middleware.go new file mode 100644 index 0000000..4e3ebf2 --- /dev/null +++ b/internal/observability/middleware.go @@ -0,0 +1,56 @@ +package observability + +import ( + "context" + "net/http" + + "github.com/google/uuid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" +) + +type contextKey string + +const RequestIDKey contextKey = "request_id" + +// responseWriter is a wrapper around http.ResponseWriter to capture the status code. +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// RequestIDMiddleware generates a unique request ID and adds it to the request context. +func RequestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := uuid.New().String() + ctx := context.WithValue(r.Context(), RequestIDKey, requestID) + w.Header().Set("X-Request-ID", requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// TracingMiddleware creates a new OpenTelemetry span for each request. +func TracingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header)) + tracer := otel.Tracer("http-server") + ctx, span := tracer.Start(ctx, "HTTP "+r.Method+" "+r.URL.Path, trace.WithAttributes( + semconv.HTTPMethodKey.String(r.Method), + semconv.HTTPURLKey.String(r.URL.String()), + )) + defer span.End() + + rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(rw, r.WithContext(ctx)) + + span.SetAttributes(attribute.Int("http.status_code", rw.statusCode)) + }) +} \ No newline at end of file diff --git a/internal/observability/tracing.go b/internal/observability/tracing.go new file mode 100644 index 0000000..e6403dd --- /dev/null +++ b/internal/observability/tracing.go @@ -0,0 +1,41 @@ +package observability + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" +) + +// TracerProvider returns a new OpenTelemetry TracerProvider. +func TracerProvider(serviceName, environment string) (*sdktrace.TracerProvider, error) { + exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) + if err != nil { + return nil, err + } + + res := resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceNameKey.String(serviceName), + semconv.DeploymentEnvironmentKey.String(environment), + ) + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + + return tp, nil +} + +// ShutdownTracerProvider gracefully shuts down the tracer provider. +func ShutdownTracerProvider(ctx context.Context, tp *sdktrace.TracerProvider) error { + return tp.Shutdown(ctx) +} \ No newline at end of file diff --git a/internal/platform/auth/middleware.go b/internal/platform/auth/middleware.go index cb379ad..025a3df 100644 --- a/internal/platform/auth/middleware.go +++ b/internal/platform/auth/middleware.go @@ -88,27 +88,29 @@ func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler { func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // For GraphQL, we want to authenticate but not block requests - // This allows for both authenticated and anonymous queries authHeader := r.Header.Get("Authorization") - if authHeader != "" { - tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader) - if err == nil { - claims, err := jwtManager.ValidateToken(tokenString) - if err == nil { - // Add claims to context for authenticated requests - ctx := context.WithValue(r.Context(), ClaimsContextKey, claims) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - } - // If token is invalid, log warning but continue - log.LogWarn("GraphQL authentication failed - continuing with anonymous access", - log.F("path", r.URL.Path)) + if authHeader == "" { + next.ServeHTTP(w, r) + return } - // Continue without authentication - next.ServeHTTP(w, r) + tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader) + if err != nil { + log.LogWarn("GraphQL authentication failed - could not extract token", log.F("error", err)) + next.ServeHTTP(w, r) // Proceed without auth + return + } + + claims, err := jwtManager.ValidateToken(tokenString) + if err != nil { + log.LogWarn("GraphQL authentication failed - invalid token", log.F("error", err)) + next.ServeHTTP(w, r) // Proceed without auth + return + } + + // Add claims to context for authenticated requests + ctx := context.WithValue(r.Context(), ClaimsContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) }) } } @@ -187,3 +189,12 @@ func ContextWithUserID(ctx context.Context, userID uint) context.Context { claims := &Claims{UserID: userID} return context.WithValue(ctx, ClaimsContextKey, claims) } + +// ContextWithAdminUser adds an admin user to the context for testing purposes. +func ContextWithAdminUser(ctx context.Context, userID uint) context.Context { + claims := &Claims{ + UserID: userID, + Role: "admin", + } + return context.WithValue(ctx, ClaimsContextKey, claims) +} diff --git a/internal/platform/log/logger.go b/internal/platform/log/logger.go index e1e18b9..41ca07a 100644 --- a/internal/platform/log/logger.go +++ b/internal/platform/log/logger.go @@ -1,32 +1,145 @@ package log import ( + "context" "fmt" - "io" - "log" - "os" - "runtime" - "strings" - "time" + "tercul/internal/observability" + + "github.com/rs/zerolog" ) -// LogLevel represents the severity level of a log message +// LogLevel represents the severity level of a log message. type LogLevel int const ( - // DebugLevel for detailed troubleshooting + // DebugLevel for detailed troubleshooting. DebugLevel LogLevel = iota - // InfoLevel for general operational information + // InfoLevel for general operational information. InfoLevel - // WarnLevel for potentially harmful situations + // WarnLevel for potentially harmful situations. WarnLevel - // ErrorLevel for error events that might still allow the application to continue + // ErrorLevel for error events that might still allow the application to continue. ErrorLevel - // FatalLevel for severe error events that will lead the application to abort + // FatalLevel for severe error events that will lead the application to abort. FatalLevel ) -// String returns the string representation of the log level +// Field represents a key-value pair for structured logging. +type Field struct { + Key string + Value interface{} +} + +// F creates a new Field. +func F(key string, value interface{}) Field { + return Field{Key: key, Value: value} +} + +// Logger provides structured logging capabilities. +type Logger struct { + *observability.Logger +} + +var defaultLogger = &Logger{observability.NewLogger("tercul", "development")} + +// Init re-initializes the default logger. This is useful for applications +// that need to configure the logger with dynamic values. +func Init(serviceName, environment string) { + defaultLogger = &Logger{observability.NewLogger(serviceName, environment)} +} + +// SetDefaultLevel sets the log level for the default logger. +func SetDefaultLevel(level LogLevel) { + var zlevel zerolog.Level + switch level { + case DebugLevel: + zlevel = zerolog.DebugLevel + case InfoLevel: + zlevel = zerolog.InfoLevel + case WarnLevel: + zlevel = zerolog.WarnLevel + case ErrorLevel: + zlevel = zerolog.ErrorLevel + case FatalLevel: + zlevel = zerolog.FatalLevel + default: + zlevel = zerolog.InfoLevel + } + zerolog.SetGlobalLevel(zlevel) +} + +func log(level LogLevel, msg string, fields ...Field) { + var event *zerolog.Event + // Access the embedded observability.Logger to get to zerolog's methods. + zlog := defaultLogger.Logger + switch level { + case DebugLevel: + event = zlog.Debug() + case InfoLevel: + event = zlog.Info() + case WarnLevel: + event = zlog.Warn() + case ErrorLevel: + event = zlog.Error() + case FatalLevel: + event = zlog.Fatal() + default: + event = zlog.Info() + } + + for _, f := range fields { + event.Interface(f.Key, f.Value) + } + event.Msg(msg) +} + +// LogDebug logs a message at debug level using the default logger. +func LogDebug(msg string, fields ...Field) { + log(DebugLevel, msg, fields...) +} + +// LogInfo logs a message at info level using the default logger. +func LogInfo(msg string, fields ...Field) { + log(InfoLevel, msg, fields...) +} + +// LogWarn logs a message at warn level using the default logger. +func LogWarn(msg string, fields ...Field) { + log(WarnLevel, msg, fields...) +} + +// LogError logs a message at error level using the default logger. +func LogError(msg string, fields ...Field) { + log(ErrorLevel, msg, fields...) +} + +// LogFatal logs a message at fatal level using the default logger and then calls os.Exit(1). +func LogFatal(msg string, fields ...Field) { + log(FatalLevel, msg, fields...) +} + +// WithFields returns a new logger with the given fields added using the default logger. +func WithFields(fields ...Field) *Logger { + sublogger := defaultLogger.With().Logger() + for _, f := range fields { + sublogger = sublogger.With().Interface(f.Key, f.Value).Logger() + } + return &Logger{&observability.Logger{&sublogger}} +} + +// WithContext returns a new logger with the given context added using the default logger. +func WithContext(ctx context.Context) *Logger { + return &Logger{defaultLogger.Ctx(ctx)} +} + +// The following functions are kept for compatibility but are now simplified or deprecated. + +// SetDefaultLogger is deprecated. Use Init. +func SetDefaultLogger(logger *Logger) { + // Deprecated: Logger is now initialized via Init. +} + +// String returns the string representation of the log level. func (l LogLevel) String() string { switch l { case DebugLevel: @@ -44,192 +157,83 @@ func (l LogLevel) String() string { } } -// Field represents a key-value pair for structured logging -type Field struct { - Key string - Value interface{} -} - -// F creates a new Field -func F(key string, value interface{}) Field { - return Field{Key: key, Value: value} -} - -// Logger provides structured logging capabilities -type Logger struct { - level LogLevel - writer io.Writer - fields []Field - context map[string]interface{} -} - -// New creates a new Logger with the specified log level and writer -func New(level LogLevel, writer io.Writer) *Logger { - if writer == nil { - writer = os.Stdout - } - return &Logger{ - level: level, - writer: writer, - fields: []Field{}, - context: make(map[string]interface{}), - } -} - -// Debug logs a message at debug level +// Debug logs a message at debug level. func (l *Logger) Debug(msg string, fields ...Field) { - if l.level <= DebugLevel { - l.log(DebugLevel, msg, fields...) - } + l.log(DebugLevel, msg, fields...) } -// Info logs a message at info level +// Info logs a message at info level. func (l *Logger) Info(msg string, fields ...Field) { - if l.level <= InfoLevel { - l.log(InfoLevel, msg, fields...) - } + l.log(InfoLevel, msg, fields...) } -// Warn logs a message at warn level +// Warn logs a message at warn level. func (l *Logger) Warn(msg string, fields ...Field) { - if l.level <= WarnLevel { - l.log(WarnLevel, msg, fields...) - } + l.log(WarnLevel, msg, fields...) } -// Error logs a message at error level +// Error logs a message at error level. func (l *Logger) Error(msg string, fields ...Field) { - if l.level <= ErrorLevel { - l.log(ErrorLevel, msg, fields...) - } + l.log(ErrorLevel, msg, fields...) } -// Fatal logs a message at fatal level and then calls os.Exit(1) +// Fatal logs a message at fatal level and then calls os.Exit(1). func (l *Logger) Fatal(msg string, fields ...Field) { - if l.level <= FatalLevel { - l.log(FatalLevel, msg, fields...) - os.Exit(1) - } + l.log(FatalLevel, msg, fields...) } -// WithFields returns a new logger with the given fields added -func (l *Logger) WithFields(fields ...Field) *Logger { - newLogger := &Logger{ - level: l.level, - writer: l.writer, - fields: append(l.fields, fields...), - context: l.context, - } - return newLogger -} - -// WithContext returns a new logger with the given context added -func (l *Logger) WithContext(ctx map[string]interface{}) *Logger { - newContext := make(map[string]interface{}) - for k, v := range l.context { - newContext[k] = v - } - for k, v := range ctx { - newContext[k] = v - } - - newLogger := &Logger{ - level: l.level, - writer: l.writer, - fields: l.fields, - context: newContext, - } - return newLogger -} - -// SetLevel sets the log level -func (l *Logger) SetLevel(level LogLevel) { - l.level = level -} - -// log formats and writes a log message func (l *Logger) log(level LogLevel, msg string, fields ...Field) { - timestamp := time.Now().Format(time.RFC3339) - - // Get caller information - _, file, line, ok := runtime.Caller(2) - caller := "unknown" - if ok { - parts := strings.Split(file, "/") - if len(parts) > 2 { - caller = fmt.Sprintf("%s:%d", parts[len(parts)-1], line) - } else { - caller = fmt.Sprintf("%s:%d", file, line) - } + var event *zerolog.Event + switch level { + case DebugLevel: + event = l.Logger.Debug() + case InfoLevel: + event = l.Logger.Info() + case WarnLevel: + event = l.Logger.Warn() + case ErrorLevel: + event = l.Logger.Error() + case FatalLevel: + event = l.Logger.Fatal() + default: + event = l.Logger.Info() } - // Format fields - allFields := append(l.fields, fields...) - fieldStr := "" - for _, field := range allFields { - fieldStr += fmt.Sprintf(" %s=%v", field.Key, field.Value) + for _, f := range fields { + event.Interface(f.Key, f.Value) } + event.Msg(msg) +} - // Format context - contextStr := "" - for k, v := range l.context { - contextStr += fmt.Sprintf(" %s=%v", k, v) - } - - // Format log message - logMsg := fmt.Sprintf("%s [%s] %s %s%s%s\n", timestamp, level.String(), caller, msg, fieldStr, contextStr) - - // Write log message - _, err := l.writer.Write([]byte(logMsg)) - if err != nil { - log.Printf("Error writing log message: %v", err) +// WithFields returns a new logger with the given fields added. +func (l *Logger) WithFields(fields ...Field) *Logger { + sublogger := l.With().Logger() + for _, f := range fields { + sublogger = sublogger.With().Interface(f.Key, f.Value).Logger() } + return &Logger{&observability.Logger{&sublogger}} } -// Global logger instance -var defaultLogger = New(InfoLevel, os.Stdout) - -// SetDefaultLogger sets the global logger instance -func SetDefaultLogger(logger *Logger) { - defaultLogger = logger +func (l *Logger) WithContext(ctx map[string]interface{}) *Logger { + // To maintain compatibility with the old API, we will convert the map to a context. + // This is not ideal and should be refactored in the future. + zlog := l.Logger.With().Logger() + for k, v := range ctx { + zlog = zlog.With().Interface(k, v).Logger() + } + return &Logger{&observability.Logger{&zlog}} } -// SetDefaultLevel sets the log level for the default logger -func SetDefaultLevel(level LogLevel) { - defaultLogger.SetLevel(level) +func (l *Logger) SetLevel(level LogLevel) { + // This now controls the global log level. + SetDefaultLevel(level) } -// LogDebug logs a message at debug level using the default logger -func LogDebug(msg string, fields ...Field) { - defaultLogger.Debug(msg, fields...) +// Fmt versions for simple string formatting +func LogInfof(format string, v ...interface{}) { + log(InfoLevel, fmt.Sprintf(format, v...)) } -// LogInfo logs a message at info level using the default logger -func LogInfo(msg string, fields ...Field) { - defaultLogger.Info(msg, fields...) -} - -// LogWarn logs a message at warn level using the default logger -func LogWarn(msg string, fields ...Field) { - defaultLogger.Warn(msg, fields...) -} - -// LogError logs a message at error level using the default logger -func LogError(msg string, fields ...Field) { - defaultLogger.Error(msg, fields...) -} - -// LogFatal logs a message at fatal level using the default logger and then calls os.Exit(1) -func LogFatal(msg string, fields ...Field) { - defaultLogger.Fatal(msg, fields...) -} - -// WithFields returns a new logger with the given fields added using the default logger -func WithFields(fields ...Field) *Logger { - return defaultLogger.WithFields(fields...) -} - -// WithContext returns a new logger with the given context added using the default logger -func WithContext(ctx map[string]interface{}) *Logger { - return defaultLogger.WithContext(ctx) -} +func LogErrorf(format string, v ...interface{}) { + log(ErrorLevel, fmt.Sprintf(format, v...)) +} \ No newline at end of file diff --git a/internal/testutil/mock_analytics_service.go b/internal/testutil/mock_analytics_service.go deleted file mode 100644 index 75e48ba..0000000 --- a/internal/testutil/mock_analytics_service.go +++ /dev/null @@ -1,102 +0,0 @@ -package testutil - -import ( - "context" - "tercul/internal/domain" - "tercul/internal/domain/work" - "time" - - "github.com/stretchr/testify/mock" -) - -// MockAnalyticsService is a mock implementation of the analytics.Service interface. -type MockAnalyticsService struct { - mock.Mock -} - -func (m *MockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { - args := m.Called(ctx, timePeriod, limit) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]*work.Work), args.Error(1) -} - -func (m *MockAnalyticsService) UpdateTrending(ctx context.Context) error { - args := m.Called(ctx) - return args.Error(0) -} - -func (m *MockAnalyticsService) IncrementWorkLikes(ctx context.Context, workID uint) { - m.Called(ctx, workID) -} - -func (m *MockAnalyticsService) IncrementTranslationLikes(ctx context.Context, translationID uint) { - m.Called(ctx, translationID) -} - -func (m *MockAnalyticsService) IncrementWorkComments(ctx context.Context, workID uint) { - m.Called(ctx, workID) -} - -func (m *MockAnalyticsService) IncrementTranslationComments(ctx context.Context, translationID uint) { - m.Called(ctx, translationID) -} - -func (m *MockAnalyticsService) IncrementWorkBookmarks(ctx context.Context, workID uint) { - m.Called(ctx, workID) -} - -func (m *MockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { - args := m.Called(ctx, workID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*work.WorkStats), args.Error(1) -} - -func (m *MockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { - args := m.Called(ctx, translationID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.TranslationStats), args.Error(1) -} - -func (m *MockAnalyticsService) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) { - args := m.Called(ctx, userID, date) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.UserEngagement), args.Error(1) -} - -func (m *MockAnalyticsService) UpdateUserEngagement(ctx context.Context, engagement *domain.UserEngagement) error { - args := m.Called(ctx, engagement) - return args.Error(0) -} - -func (m *MockAnalyticsService) IncrementWorkCounter(ctx context.Context, workID uint, counter string, value int) error { - args := m.Called(ctx, workID, counter, value) - return args.Error(0) -} - -func (m *MockAnalyticsService) IncrementTranslationCounter(ctx context.Context, translationID uint, counter string, value int) error { - args := m.Called(ctx, translationID, counter, value) - return args.Error(0) -} - -func (m *MockAnalyticsService) UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error { - args := m.Called(ctx, workID, stats) - return args.Error(0) -} - -func (m *MockAnalyticsService) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { - args := m.Called(ctx, translationID, stats) - return args.Error(0) -} - -func (m *MockAnalyticsService) UpdateTrendingWorks(ctx context.Context, timePeriod string, trendingWorks []*domain.Trending) error { - args := m.Called(ctx, timePeriod, trendingWorks) - return args.Error(0) -} \ No newline at end of file diff --git a/internal/testutil/mock_like_repository.go b/internal/testutil/mock_like_repository.go deleted file mode 100644 index b54fd3a..0000000 --- a/internal/testutil/mock_like_repository.go +++ /dev/null @@ -1,152 +0,0 @@ -package testutil - -import ( - "context" - "tercul/internal/domain" - - "github.com/stretchr/testify/mock" - "gorm.io/gorm" -) - -// MockLikeRepository is a mock implementation of the LikeRepository interface. -type MockLikeRepository struct { - mock.Mock - Likes []*domain.Like // Keep for other potential tests, but new mocks will use testify -} - -// NewMockLikeRepository creates a new MockLikeRepository. -func NewMockLikeRepository() *MockLikeRepository { - return &MockLikeRepository{Likes: []*domain.Like{}} -} - -// Create uses the mock's Called method. -func (m *MockLikeRepository) Create(ctx context.Context, like *domain.Like) error { - args := m.Called(ctx, like) - return args.Error(0) -} - -// GetByID retrieves a like by its ID from the mock repository. -func (m *MockLikeRepository) GetByID(ctx context.Context, id uint) (*domain.Like, error) { - args := m.Called(ctx, id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.Like), args.Error(1) -} - -// ListByUserID retrieves likes by their user ID from the mock repository. -func (m *MockLikeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { - var likes []domain.Like - for _, l := range m.Likes { - if l.UserID == userID { - likes = append(likes, *l) - } - } - return likes, nil -} - -// ListByWorkID retrieves likes by their work ID from the mock repository. -func (m *MockLikeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { - var likes []domain.Like - for _, l := range m.Likes { - if l.WorkID != nil && *l.WorkID == workID { - likes = append(likes, *l) - } - } - return likes, nil -} - -// ListByTranslationID retrieves likes by their translation ID from the mock repository. -func (m *MockLikeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { - var likes []domain.Like - for _, l := range m.Likes { - if l.TranslationID != nil && *l.TranslationID == translationID { - likes = append(likes, *l) - } - } - return likes, nil -} - -// ListByCommentID retrieves likes by their comment ID from the mock repository. -func (m *MockLikeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { - var likes []domain.Like - for _, l := range m.Likes { - if l.CommentID != nil && *l.CommentID == commentID { - likes = append(likes, *l) - } - } - return likes, nil -} - -// The rest of the BaseRepository methods can be stubbed out or implemented as needed. - -func (m *MockLikeRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { - return m.Create(ctx, entity) -} - -func (m *MockLikeRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Like, error) { - return m.GetByID(ctx, id) -} - -func (m *MockLikeRepository) Update(ctx context.Context, entity *domain.Like) error { - args := m.Called(ctx, entity) - return args.Error(0) -} - -func (m *MockLikeRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { - return m.Update(ctx, entity) -} - -func (m *MockLikeRepository) Delete(ctx context.Context, id uint) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockLikeRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { - return m.Delete(ctx, id) -} - -func (m *MockLikeRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Like], error) { - panic("not implemented") -} - -func (m *MockLikeRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Like, error) { - panic("not implemented") -} - -func (m *MockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { - var likes []domain.Like - for _, l := range m.Likes { - likes = append(likes, *l) - } - return likes, nil -} - -func (m *MockLikeRepository) Count(ctx context.Context) (int64, error) { - return int64(len(m.Likes)), nil -} - -func (m *MockLikeRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - panic("not implemented") -} - -func (m *MockLikeRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Like, error) { - return m.GetByID(ctx, id) -} - -func (m *MockLikeRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Like, error) { - panic("not implemented") -} - -func (m *MockLikeRepository) Exists(ctx context.Context, id uint) (bool, error) { - args := m.Called(ctx, id) - return args.Bool(0), args.Error(1) -} - -func (m *MockLikeRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { - return nil, nil -} - -func (m *MockLikeRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { - return fn(nil) -} \ No newline at end of file diff --git a/internal/testutil/mock_work_repository.go b/internal/testutil/mock_work_repository.go deleted file mode 100644 index 974a3a4..0000000 --- a/internal/testutil/mock_work_repository.go +++ /dev/null @@ -1,125 +0,0 @@ -package testutil - -import ( - "context" - "tercul/internal/domain" - "tercul/internal/domain/work" - - "github.com/stretchr/testify/mock" - "gorm.io/gorm" -) - -// MockWorkRepository is a mock implementation of the WorkRepository interface. -type MockWorkRepository struct { - mock.Mock - Works []*work.Work -} - -// NewMockWorkRepository creates a new MockWorkRepository. -func NewMockWorkRepository() *MockWorkRepository { - return &MockWorkRepository{Works: []*work.Work{}} -} - -// Create adds a new work to the mock repository. -func (m *MockWorkRepository) Create(ctx context.Context, work *work.Work) error { - work.ID = uint(len(m.Works) + 1) - m.Works = append(m.Works, work) - return nil -} - -// GetByID retrieves a work by its ID from the mock repository. -func (m *MockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) { - for _, w := range m.Works { - if w.ID == id { - return w, nil - } - } - return nil, gorm.ErrRecordNotFound -} - -// Exists uses the mock's Called method. -func (m *MockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { - args := m.Called(ctx, id) - return args.Bool(0), args.Error(1) -} - -// The rest of the WorkRepository and BaseRepository methods can be stubbed out. -func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { - panic("not implemented") -} -func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { - panic("not implemented") -} -func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { - panic("not implemented") -} -func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - panic("not implemented") -} -func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { - return m.GetByID(ctx, id) -} -func (m *MockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - panic("not implemented") -} -func (m *MockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { - return m.Create(ctx, entity) -} -func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { - return m.GetByID(ctx, id) -} -func (m *MockWorkRepository) Update(ctx context.Context, entity *work.Work) error { - for i, w := range m.Works { - if w.ID == entity.ID { - m.Works[i] = entity - return nil - } - } - return gorm.ErrRecordNotFound -} -func (m *MockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { - return m.Update(ctx, entity) -} -func (m *MockWorkRepository) Delete(ctx context.Context, id uint) error { - for i, w := range m.Works { - if w.ID == id { - m.Works = append(m.Works[:i], m.Works[i+1:]...) - return nil - } - } - return gorm.ErrRecordNotFound -} -func (m *MockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { - return m.Delete(ctx, id) -} -func (m *MockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - panic("not implemented") -} -func (m *MockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { - panic("not implemented") -} -func (m *MockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { - var works []work.Work - for _, w := range m.Works { - works = append(works, *w) - } - return works, nil -} -func (m *MockWorkRepository) Count(ctx context.Context) (int64, error) { - return int64(len(m.Works)), nil -} -func (m *MockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - panic("not implemented") -} -func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) { - return m.GetByID(ctx, id) -} -func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) { - panic("not implemented") -} -func (m *MockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { - return nil, nil -} -func (m *MockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { - return fn(nil) -} \ No newline at end of file diff --git a/internal/testutil/simple_test_utils.go b/internal/testutil/simple_test_utils.go index 861f2fd..9327640 100644 --- a/internal/testutil/simple_test_utils.go +++ b/internal/testutil/simple_test_utils.go @@ -2,45 +2,8 @@ package testutil import ( "context" - graph "tercul/internal/adapters/graphql" - "tercul/internal/app" - "tercul/internal/app/localization" - "tercul/internal/app/work" - "tercul/internal/domain" - domain_localization "tercul/internal/domain/localization" - domain_work "tercul/internal/domain/work" - - "github.com/stretchr/testify/suite" ) -// SimpleTestSuite provides a minimal test environment with just the essentials -type SimpleTestSuite struct { - suite.Suite - WorkRepo *MockWorkRepository - WorkService *work.Service - MockSearchClient *MockSearchClient -} - -// MockSearchClient is a mock implementation of the search.SearchClient interface. -type MockSearchClient struct{} - -// IndexWork is the mock implementation of the IndexWork method. -func (m *MockSearchClient) IndexWork(ctx context.Context, work *domain_work.Work, pipeline string) error { - return nil -} - -// SetupSuite sets up the test suite -func (s *SimpleTestSuite) SetupSuite() { - s.WorkRepo = NewMockWorkRepository() - s.MockSearchClient = &MockSearchClient{} - s.WorkService = work.NewService(s.WorkRepo, s.MockSearchClient) -} - -// SetupTest resets test data for each test -func (s *SimpleTestSuite) SetupTest() { - s.WorkRepo = NewMockWorkRepository() -} - // MockLocalizationRepository is a mock implementation of the localization repository. type MockLocalizationRepository struct{} @@ -59,34 +22,4 @@ func (m *MockLocalizationRepository) GetTranslations(ctx context.Context, keys [ // GetAuthorBiography is a mock implementation of the GetAuthorBiography method. func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { return "This is a mock biography.", nil -} - -// GetResolver returns a minimal GraphQL resolver for testing -func (s *SimpleTestSuite) GetResolver() *graph.Resolver { - var mockLocalizationRepo domain_localization.LocalizationRepository = &MockLocalizationRepository{} - localizationService := localization.NewService(mockLocalizationRepo) - - return &graph.Resolver{ - App: &app.Application{ - Work: s.WorkService, - Localization: localizationService, - }, - } -} - -// CreateTestWork creates a test work with optional content -func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) *domain_work.Work { - work := &domain_work.Work{ - Title: title, - TranslatableModel: domain.TranslatableModel{Language: language}, - } - - // Add work to the mock repository - createdWork, err := s.WorkService.Commands.CreateWork(context.Background(), work) - s.Require().NoError(err) - - // If content is provided, we'll need to handle it differently - // since the mock repository doesn't support translations yet - // For now, just return the work - return createdWork } \ No newline at end of file