Merge pull request #9 from SamyRai/feature/observability-stack

feat: Implement observability stack
This commit is contained in:
Damir Mukimov 2025-10-05 01:49:14 +02:00 committed by GitHub
commit 7b2478273c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 3591 additions and 958 deletions

17
AGENTS.md Normal file
View File

@ -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.

View File

@ -13,6 +13,7 @@ import (
graph "tercul/internal/adapters/graphql" graph "tercul/internal/adapters/graphql"
dbsql "tercul/internal/data/sql" dbsql "tercul/internal/data/sql"
"tercul/internal/jobs/linguistics" "tercul/internal/jobs/linguistics"
"tercul/internal/observability"
"tercul/internal/platform/auth" "tercul/internal/platform/auth"
"tercul/internal/platform/config" "tercul/internal/platform/config"
"tercul/internal/platform/db" "tercul/internal/platform/db"
@ -22,6 +23,7 @@ import (
"github.com/99designs/gqlgen/graphql/playground" "github.com/99designs/gqlgen/graphql/playground"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
"github.com/prometheus/client_golang/prometheus"
"github.com/weaviate/weaviate-go-client/v5/weaviate" "github.com/weaviate/weaviate-go-client/v5/weaviate"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -54,8 +56,24 @@ func main() {
// Load configuration from environment variables // Load configuration from environment variables
config.LoadConfig() config.LoadConfig()
// Initialize structured logger with appropriate log level // Initialize logger
log.SetDefaultLevel(log.InfoLevel) 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.LogInfo("Starting Tercul application",
log.F("environment", config.Cfg.Environment), log.F("environment", config.Cfg.Environment),
log.F("version", "1.0.0")) log.F("version", "1.0.0"))
@ -106,7 +124,7 @@ func main() {
} }
jwtManager := auth.NewJWTManager() jwtManager := auth.NewJWTManager()
srv := NewServerWithAuth(resolver, jwtManager) srv := NewServerWithAuth(resolver, jwtManager, metrics)
graphQLServer := &http.Server{ graphQLServer := &http.Server{
Addr: config.Cfg.ServerPort, Addr: config.Cfg.ServerPort,
Handler: srv, Handler: srv,
@ -121,6 +139,13 @@ func main() {
} }
log.LogInfo("GraphQL playground created successfully", log.F("port", config.Cfg.PlaygroundPort)) 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 // Start HTTP servers in goroutines
go func() { go func() {
log.LogInfo("Starting GraphQL server", 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 // Wait for interrupt signal to gracefully shutdown the servers
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@ -161,5 +193,9 @@ func main() {
log.F("error", err)) 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") log.LogInfo("All servers shutdown successfully")
} }

View File

@ -3,6 +3,7 @@ package main
import ( import (
"net/http" "net/http"
"tercul/internal/adapters/graphql" "tercul/internal/adapters/graphql"
"tercul/internal/observability"
"tercul/internal/platform/auth" "tercul/internal/platform/auth"
"github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler"
@ -21,18 +22,26 @@ func NewServer(resolver *graphql.Resolver) http.Handler {
return mux return mux
} }
// NewServerWithAuth creates a new GraphQL server with authentication middleware // NewServerWithAuth creates a new GraphQL server with authentication and observability middleware
func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler { func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics) http.Handler {
c := graphql.Config{Resolvers: resolver} c := graphql.Config{Resolvers: resolver}
c.Directives.Binding = graphql.Binding c.Directives.Binding = graphql.Binding
// Create the server with the custom error presenter
srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c))
srv.SetErrorPresenter(graphql.NewErrorPresenter())
// Apply authentication middleware to GraphQL endpoint // Create a middleware chain
authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv) 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 := http.NewServeMux()
mux.Handle("/query", authHandler) mux.Handle("/query", chain)
return mux return mux
} }

36
go.mod
View File

@ -8,16 +8,23 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.27.0
github.com/golang-jwt/jwt/v5 v5.3.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/hashicorp/golang-lru/v2 v2.0.7
github.com/hibiken/asynq v0.25.1 github.com/hibiken/asynq v0.25.1
github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc
github.com/pemistahl/lingua-go v1.4.0 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/redis/go-redis/v9 v9.13.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/vektah/gqlparser/v2 v2.5.30 github.com/vektah/gqlparser/v2 v2.5.30
github.com/weaviate/weaviate v1.32.6 github.com/weaviate/weaviate v1.33.0-rc.1
github.com/weaviate/weaviate-go-client/v5 v5.4.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 golang.org/x/crypto v0.41.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@ -25,13 +32,13 @@ require (
) )
require ( require (
ariga.io/atlas-go-sdk v0.5.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/ClickHouse/ch-go v0.67.0 // indirect github.com/ClickHouse/ch-go v0.61.1 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.17.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.12 // indirect github.com/coder/websocket v1.8.12 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // 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/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.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/analysis v0.23.0 // indirect
github.com/go-openapi/errors v0.22.0 // indirect github.com/go-openapi/errors v0.22.0 // indirect
github.com/go-openapi/jsonpointer v0.21.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-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // 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/gorilla/websocket v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // 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-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mfridman/interpolate v0.0.2 // 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/microsoft/go-mssqldb v1.9.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // 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/ncruces/go-strftime v0.1.9 // indirect
github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid v1.3.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // 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/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // 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/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect
github.com/ziutek/mymysql v1.5.4 // indirect github.com/ziutek/mymysql v1.5.4 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.26.0 // 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/time v0.12.0 // indirect
golang.org/x/tools v0.35.0 // indirect golang.org/x/tools v0.35.0 // indirect
gonum.org/v1/gonum v0.15.1 // indirect gonum.org/v1/gonum v0.15.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/grpc v1.73.0 // indirect google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

83
go.sum
View File

@ -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.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 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 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 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/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.61.1 h1:j5rx3qnvcnYjhnP1IdXE/vdIRQiqgwAzyqOaasA6QCw=
github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18= github.com/ClickHouse/ch-go v0.61.1/go.mod h1:myxt/JZgy2BYHFGQqzmaIpbfr5CMbs3YHVULaWQj5YU=
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 h1:PbwsHBgqXRydU7jKULD1C8CHmifczffvQqmFvltM2W4= github.com/ClickHouse/clickhouse-go/v2 v2.17.1 h1:ZCmAYWpu75IyEi7+Yrs/uaAjiCGY5wfW5kXo64exkX4=
github.com/ClickHouse/clickhouse-go/v2 v2.40.1/go.mod h1:GDzSBLVhladVm8V01aEB36IoBOVLLICfyeuiIp/8Ezc= 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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3 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-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 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 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/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 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 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 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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= 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/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 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= 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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 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.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/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/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/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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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/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/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.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 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc h1:Zvn/U2151AlhFbOIIZivbnpvExjD/8rlQsO/RaNJQw0= github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc 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/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/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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= 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 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs=
github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ=
github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= 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.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.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 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 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.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.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.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0 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/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 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw=
github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= 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.33.0-rc.1 h1:3Kol9BmA9JOj1I4vOkz0tu4A87K3dKVAnr8k8DMhBs8=
github.com/weaviate/weaviate v1.32.6/go.mod h1:hzzhAOYxgKe+B2jxZJtaWMIdElcXXn+RQyQ7ccQORNg= github.com/weaviate/weaviate v1.33.0-rc.1/go.mod h1:MmHF/hZDL0I8j0qAMEa9/TS4ISLaYlIp1Bc3e/n3eUU=
github.com/weaviate/weaviate-go-client/v5 v5.4.1 h1:hfKocGPe11IUr4XsLp3q9hJYck0I2yIHGlFBpLqb/F4= github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4=
github.com/weaviate/weaviate-go-client/v5 v5.4.1/go.mod h1:l72EnmCLj9LCQkR8S7nN7Y1VqGMmL3Um8exhFkMmfwk= 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/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.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= 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.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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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-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-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-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.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 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-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-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-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 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-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
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/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 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.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= 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.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 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-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-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View File

@ -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)
}

View File

@ -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")
})
}

View File

@ -0,0 +1,49 @@
package graphql
import (
"context"
"errors"
"tercul/internal/domain"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
)
// NewErrorPresenter creates a custom error presenter for gqlgen.
func NewErrorPresenter() graphql.ErrorPresenterFunc {
return func(ctx context.Context, e error) *gqlerror.Error {
gqlErr := graphql.DefaultErrorPresenter(ctx, e)
// Unwrap the error to find the root cause.
originalErr := errors.Unwrap(e)
if originalErr == nil {
originalErr = e
}
// Check for custom application errors and format them.
switch {
case errors.Is(originalErr, domain.ErrNotFound):
gqlErr.Message = "The requested resource was not found."
gqlErr.Extensions = map[string]interface{}{"code": "NOT_FOUND"}
case errors.Is(originalErr, domain.ErrUnauthorized):
gqlErr.Message = "You must be logged in to perform this action."
gqlErr.Extensions = map[string]interface{}{"code": "UNAUTHENTICATED"}
case errors.Is(originalErr, domain.ErrForbidden):
gqlErr.Message = "You are not authorized to perform this action."
gqlErr.Extensions = map[string]interface{}{"code": "FORBIDDEN"}
case errors.Is(originalErr, domain.ErrValidation):
// Keep the detailed message from the validation error.
gqlErr.Message = originalErr.Error()
gqlErr.Extensions = map[string]interface{}{"code": "VALIDATION_FAILED"}
case errors.Is(originalErr, domain.ErrConflict):
gqlErr.Message = "A conflict occurred with the current state of the resource."
gqlErr.Extensions = map[string]interface{}{"code": "CONFLICT"}
default:
// For all other errors, return a generic message to avoid leaking implementation details.
gqlErr.Message = "An unexpected internal error occurred."
gqlErr.Extensions = map[string]interface{}{"code": "INTERNAL_SERVER_ERROR"}
}
return gqlErr
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ package graphql_test
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -27,7 +28,7 @@ type graphQLTestServer interface {
} }
// executeGraphQL executes a GraphQL query against a test server and decodes the response. // 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{ request := GraphQLRequest{
Query: query, Query: query,
Variables: variables, Variables: variables,
@ -38,7 +39,14 @@ func executeGraphQL[T any](s graphQLTestServer, query string, variables map[stri
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -18,10 +18,12 @@ import (
"tercul/internal/app/translation" "tercul/internal/app/translation"
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/domain/work" "tercul/internal/domain/work"
"tercul/internal/observability"
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
"tercul/internal/testutil" "tercul/internal/testutil"
"github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -48,14 +50,21 @@ func (s *GraphQLIntegrationSuite) CreateAuthenticatedUser(username, email string
// Update user role if necessary // Update user role if necessary
user := authResponse.User user := authResponse.User
token := authResponse.Token
if user.Role != role { if user.Role != role {
// This part is tricky. There is no UpdateUserRole command. // This part is tricky. There is no UpdateUserRole command.
// For a test, I can update the DB directly. // For a test, I can update the DB directly.
s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role) s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role)
user.Role = role user.Role = role
// Re-generate 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 // SetupSuite sets up the test suite
@ -64,16 +73,27 @@ func (s *GraphQLIntegrationSuite) SetupSuite() {
// Create GraphQL server with the test resolver // Create GraphQL server with the test resolver
resolver := &graph.Resolver{App: s.App} 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 // Create JWT manager and middleware
jwtManager := platform_auth.NewJWTManager() 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) { // Create a middleware chain
srv.ServeHTTP(w, r) 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() s.client = s.server.Client()
} }
@ -225,7 +245,8 @@ func (s *GraphQLIntegrationSuite) TestCreateWork() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
@ -330,7 +351,8 @@ func (s *GraphQLIntegrationSuite) TestCreateWorkValidation() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
@ -362,7 +384,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateWorkValidation() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
@ -390,7 +413,8 @@ func (s *GraphQLIntegrationSuite) TestCreateAuthorValidation() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
@ -423,7 +447,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
@ -455,7 +480,8 @@ func (s *GraphQLIntegrationSuite) TestCreateTranslationValidation() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
@ -496,7 +522,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors") 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() { s.Run("should delete a work", func() {
// Arrange // Arrange
work := s.CreateTestWork("Test Work", "en", "Test content") work := s.CreateTestWork("Test Work", "en", "Test content")
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
// Define the mutation // Define the mutation
mutation := ` mutation := `
@ -522,7 +550,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() {
} }
// Execute the mutation // Execute the mutation
response, err := executeGraphQL[any](s, mutation, variables, nil) response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
s.Require().NoError(err) s.Require().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
@ -540,6 +568,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() {
// Arrange // Arrange
createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"})
s.Require().NoError(err) s.Require().NoError(err)
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
// Define the mutation // Define the mutation
mutation := ` mutation := `
@ -554,7 +583,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
@ -579,6 +608,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
TranslatableType: "works", TranslatableType: "works",
}) })
s.Require().NoError(err) s.Require().NoError(err)
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
// Define the mutation // Define the mutation
mutation := ` mutation := `
@ -593,7 +623,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
} }
// Execute the mutation // 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().NoError(err)
s.Require().NotNil(response) s.Require().NotNil(response)
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors") s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")

View File

@ -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)
}

View File

@ -13,7 +13,6 @@ import (
"tercul/internal/app/like" "tercul/internal/app/like"
"tercul/internal/domain" "tercul/internal/domain"
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
"tercul/internal/testutil"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
@ -23,20 +22,20 @@ import (
type LikeResolversUnitSuite struct { type LikeResolversUnitSuite struct {
suite.Suite suite.Suite
resolver *graphql.Resolver resolver *graphql.Resolver
mockLikeRepo *testutil.MockLikeRepository mockLikeRepo *mockLikeRepository
mockWorkRepo *testutil.MockWorkRepository mockWorkRepo *mockWorkRepository
mockAnalyticsSvc *testutil.MockAnalyticsService mockAnalyticsSvc *mockAnalyticsService
} }
func (s *LikeResolversUnitSuite) SetupTest() { func (s *LikeResolversUnitSuite) SetupTest() {
// 1. Create mock repositories // 1. Create mock repositories
s.mockLikeRepo = new(testutil.MockLikeRepository) s.mockLikeRepo = new(mockLikeRepository)
s.mockWorkRepo = new(testutil.MockWorkRepository) s.mockWorkRepo = new(mockWorkRepository)
s.mockAnalyticsSvc = new(testutil.MockAnalyticsService) s.mockAnalyticsSvc = new(mockAnalyticsService)
// 2. Create real services with mock repositories // 2. Create real services with mock repositories
likeService := like.NewService(s.mockLikeRepo) 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 // 3. Create the resolver with the services
s.resolver = &graphql.Resolver{ s.resolver = &graphql.Resolver{

View File

@ -60,14 +60,25 @@ type Book struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Language string `json:"language"` Language string `json:"language"`
Description *string `json:"description,omitempty"`
Isbn *string `json:"isbn,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"` Works []*Work `json:"works,omitempty"`
Authors []*Author `json:"authors,omitempty"`
Stats *BookStats `json:"stats,omitempty"` Stats *BookStats `json:"stats,omitempty"`
Copyright *Copyright `json:"copyright,omitempty"` Copyright *Copyright `json:"copyright,omitempty"`
CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,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 { type BookStats struct {
ID string `json:"id"` ID string `json:"id"`
Sales int32 `json:"sales"` Sales int32 `json:"sales"`

View File

@ -127,14 +127,25 @@ type Book {
id: ID! id: ID!
name: String! name: String!
language: String! language: String!
description: String
isbn: String
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
works: [Work!] works: [Work!]
authors: [Author!]
stats: BookStats stats: BookStats
copyright: Copyright copyright: Copyright
copyrightClaims: [CopyrightClaim!] copyrightClaims: [CopyrightClaim!]
} }
input BookInput {
name: String!
language: String!
description: String
isbn: String
authorIds: [ID!]
}
type Collection { type Collection {
id: ID! id: ID!
name: String! name: String!
@ -453,8 +464,6 @@ type Edge {
scalar JSON scalar JSON
directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
# Queries # Queries
type Query { type Query {
# Work queries # Work queries
@ -478,6 +487,10 @@ type Query {
offset: Int offset: Int
): [Translation!]! ): [Translation!]!
# Book queries
book(id: ID!): Book
books(limit: Int, offset: Int): [Book!]!
# Author queries # Author queries
author(id: ID!): Author author(id: ID!): Author
authors( authors(
@ -568,6 +581,11 @@ type Mutation {
updateTranslation(id: ID!, input: TranslationInput!): Translation! updateTranslation(id: ID!, input: TranslationInput!): Translation!
deleteTranslation(id: ID!): Boolean! deleteTranslation(id: ID!): Boolean!
# Book mutations
createBook(input: BookInput!): Book!
updateBook(id: ID!, input: BookInput!): Book!
deleteBook(id: ID!): Boolean!
# Author mutations # Author mutations
createAuthor(input: AuthorInput!): Author! createAuthor(input: AuthorInput!): Author!
updateAuthor(id: ID!, input: AuthorInput!): Author! updateAuthor(id: ID!, input: AuthorInput!): Author!
@ -618,16 +636,16 @@ type Mutation {
# Input types # Input types
input LoginInput { input LoginInput {
email: String! email: String! @binding(constraint: "required,email")
password: String! password: String! @binding(constraint: "required")
} }
input RegisterInput { input RegisterInput {
username: String! username: String! @binding(constraint: "required,min=3,max=50")
email: String! email: String! @binding(constraint: "required,email")
password: String! password: String! @binding(constraint: "required,min=8")
firstName: String! firstName: String! @binding(constraint: "required")
lastName: String! lastName: String! @binding(constraint: "required")
} }
type AuthPayload { type AuthPayload {
@ -636,8 +654,8 @@ type AuthPayload {
} }
input WorkInput { input WorkInput {
name: String! name: String! @binding(constraint: "required,min=2")
language: String! language: String! @binding(constraint: "required,len=2")
content: String content: String
authorIds: [ID!] authorIds: [ID!]
tagIds: [ID!] tagIds: [ID!]
@ -645,15 +663,15 @@ input WorkInput {
} }
input TranslationInput { input TranslationInput {
name: String! name: String! @binding(constraint: "required,min=2")
language: String! language: String! @binding(constraint: "required,len=2")
content: String content: String
workId: ID! workId: ID!
} }
input AuthorInput { input AuthorInput {
name: String! name: String! @binding(constraint: "required,min=2")
language: String! language: String! @binding(constraint: "required,len=2")
biography: String biography: String
birthDate: String birthDate: String
deathDate: String deathDate: String
@ -711,3 +729,5 @@ input ContributionInput {
translationId: ID translationId: ID
status: ContributionStatus status: ContributionStatus
} }
directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION

View File

@ -11,11 +11,13 @@ import (
"tercul/internal/adapters/graphql/model" "tercul/internal/adapters/graphql/model"
"tercul/internal/app/auth" "tercul/internal/app/auth"
"tercul/internal/app/author" "tercul/internal/app/author"
"tercul/internal/app/book"
"tercul/internal/app/bookmark" "tercul/internal/app/bookmark"
"tercul/internal/app/collection" "tercul/internal/app/collection"
"tercul/internal/app/comment" "tercul/internal/app/comment"
"tercul/internal/app/like" "tercul/internal/app/like"
"tercul/internal/app/translation" "tercul/internal/app/translation"
"tercul/internal/app/user"
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/domain/work" "tercul/internal/domain/work"
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
@ -88,8 +90,8 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*
// CreateWork is the resolver for the createWork field. // CreateWork is the resolver for the createWork field.
func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) { func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) {
if err := validateWorkInput(input); err != nil { if err := Validate(input); err != nil {
return nil, fmt.Errorf("%w: %v", ErrValidation, err) return nil, err
} }
// Create domain model // Create domain model
workModel := &work.Work{ workModel := &work.Work{
@ -131,12 +133,13 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput
// UpdateWork is the resolver for the updateWork field. // UpdateWork is the resolver for the updateWork field.
func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) { func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) {
if err := validateWorkInput(input); err != nil { if err := Validate(input); err != nil {
return nil, fmt.Errorf("%w: %v", ErrValidation, err) return nil, err
} }
workID, err := strconv.ParseUint(id, 10, 32) workID, err := strconv.ParseUint(id, 10, 32)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid work ID: %v", err) return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
} }
// Create domain model // 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) { func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, error) {
workID, err := strconv.ParseUint(id, 10, 32) workID, err := strconv.ParseUint(id, 10, 32)
if err != nil { 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)) 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. // CreateTranslation is the resolver for the createTranslation field.
func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) { func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) {
if err := validateTranslationInput(input); err != nil { if err := Validate(input); err != nil {
return nil, fmt.Errorf("%w: %v", ErrValidation, err) return nil, err
} }
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) workID, err := strconv.ParseUint(input.WorkID, 10, 32)
if err != nil { 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 // 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. // UpdateTranslation is the resolver for the updateTranslation field.
func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) { func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) {
if err := validateTranslationInput(input); err != nil { if err := Validate(input); err != nil {
return nil, fmt.Errorf("%w: %v", ErrValidation, err) return nil, err
} }
translationID, err := strconv.ParseUint(id, 10, 32) translationID, err := strconv.ParseUint(id, 10, 32)
if err != nil { if err != nil {
@ -274,10 +286,85 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo
return true, nil 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. // CreateAuthor is the resolver for the createAuthor field.
func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) { func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) {
if err := validateAuthorInput(input); err != nil { if err := Validate(input); err != nil {
return nil, fmt.Errorf("%w: %v", ErrValidation, err) return nil, err
} }
// Call author service // Call author service
createInput := author.CreateAuthorInput{ createInput := author.CreateAuthorInput{
@ -298,8 +385,8 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI
// UpdateAuthor is the resolver for the updateAuthor field. // UpdateAuthor is the resolver for the updateAuthor field.
func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) { func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) {
if err := validateAuthorInput(input); err != nil { if err := Validate(input); err != nil {
return nil, fmt.Errorf("%w: %v", ErrValidation, err) return nil, err
} }
authorID, err := strconv.ParseUint(id, 10, 32) authorID, err := strconv.ParseUint(id, 10, 32)
if err != nil { if err != nil {
@ -341,7 +428,78 @@ func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, e
// UpdateUser is the resolver for the updateUser field. // UpdateUser is the resolver for the updateUser field.
func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input model.UserInput) (*model.User, error) { func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input model.UserInput) (*model.User, error) {
panic(fmt.Errorf("not implemented: UpdateUser - updateUser")) if err := Validate(input); err != nil {
return nil, err
}
userID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid user ID: %v", err)
}
updateInput := user.UpdateUserInput{
ID: uint(userID),
Username: input.Username,
Email: input.Email,
Password: input.Password,
FirstName: input.FirstName,
LastName: input.LastName,
DisplayName: input.DisplayName,
Bio: input.Bio,
AvatarURL: input.AvatarURL,
Verified: input.Verified,
Active: input.Active,
}
if input.Role != nil {
role := domain.UserRole(input.Role.String())
updateInput.Role = &role
}
if input.CountryID != nil {
countryID, err := strconv.ParseUint(*input.CountryID, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid country ID: %v", err)
}
uid := uint(countryID)
updateInput.CountryID = &uid
}
if input.CityID != nil {
cityID, err := strconv.ParseUint(*input.CityID, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid city ID: %v", err)
}
uid := uint(cityID)
updateInput.CityID = &uid
}
if input.AddressID != nil {
addressID, err := strconv.ParseUint(*input.AddressID, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid address ID: %v", err)
}
uid := uint(addressID)
updateInput.AddressID = &uid
}
updatedUser, err := r.App.User.Commands.UpdateUser(ctx, updateInput)
if err != nil {
return nil, err
}
// Convert to GraphQL model
return &model.User{
ID: fmt.Sprintf("%d", updatedUser.ID),
Username: updatedUser.Username,
Email: updatedUser.Email,
FirstName: &updatedUser.FirstName,
LastName: &updatedUser.LastName,
DisplayName: &updatedUser.DisplayName,
Bio: &updatedUser.Bio,
AvatarURL: &updatedUser.AvatarURL,
Role: model.UserRole(updatedUser.Role),
Verified: updatedUser.Verified,
Active: updatedUser.Active,
}, nil
} }
// DeleteUser is the resolver for the deleteUser field. // DeleteUser is the resolver for the deleteUser field.
@ -990,6 +1148,51 @@ func (r *queryResolver) Translations(ctx context.Context, workID string, languag
panic(fmt.Errorf("not implemented: Translations - translations")) 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. // Author is the resolver for the author field.
func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, error) { func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, error) {
panic(fmt.Errorf("not implemented: Author - author")) panic(fmt.Errorf("not implemented: Author - author"))
@ -1264,63 +1467,3 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver } type mutationResolver struct{ *Resolver }
type queryResolver 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 }
*/

View File

@ -4,54 +4,30 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"tercul/internal/adapters/graphql/model" "tercul/internal/domain"
"github.com/asaskevich/govalidator" "github.com/go-playground/validator/v10"
) )
var ErrValidation = errors.New("validation failed") // The 'validate' variable is declared in binding.go and is used here.
func validateWorkInput(input model.WorkInput) error { // Validate performs validation on a struct using the validator library.
name := strings.TrimSpace(input.Name) func Validate(s interface{}) error {
if len(name) < 3 { err := validate.Struct(s)
return fmt.Errorf("name must be at least 3 characters long") if err == nil {
return nil
} }
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
return fmt.Errorf("name can only contain letters, numbers, and spaces")
}
if len(input.Language) != 2 {
return fmt.Errorf("language must be a 2-character code")
}
return nil
}
func validateAuthorInput(input model.AuthorInput) error { var validationErrors validator.ValidationErrors
name := strings.TrimSpace(input.Name) if errors.As(err, &validationErrors) {
if len(name) < 3 { var errorMessages []string
return fmt.Errorf("name must be at least 3 characters long") for _, err := range validationErrors {
// Customize error messages here if needed.
errorMessages = append(errorMessages, fmt.Sprintf("field '%s' failed on the '%s' tag", err.Field(), err.Tag()))
}
return fmt.Errorf("%w: %s", domain.ErrValidation, strings.Join(errorMessages, "; "))
} }
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
return fmt.Errorf("name can only contain letters, numbers, and spaces")
}
if len(input.Language) != 2 {
return fmt.Errorf("language must be a 2-character code")
}
return nil
}
func validateTranslationInput(input model.TranslationInput) error { // For other unexpected errors, like invalid validation input.
name := strings.TrimSpace(input.Name) return fmt.Errorf("unexpected error during validation: %w", err)
if len(name) < 3 {
return fmt.Errorf("name must be at least 3 characters long")
}
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
return fmt.Errorf("name can only contain letters, numbers, and spaces")
}
if len(input.Language) != 2 {
return fmt.Errorf("language must be a 2-character code")
}
if input.WorkID == "" {
return fmt.Errorf("workId is required")
}
return nil
} }

View File

@ -0,0 +1,101 @@
package graphql_test
import (
"context"
"tercul/internal/domain"
"tercul/internal/domain/work"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
// mockWorkRepository is a mock implementation of the WorkRepository interface.
type mockWorkRepository struct {
mock.Mock
}
func (m *mockWorkRepository) Create(ctx context.Context, entity *work.Work) error {
args := m.Called(ctx, entity)
return args.Error(0)
}
func (m *mockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
return m.Create(ctx, entity)
}
func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*work.Work), args.Error(1)
}
func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) {
return m.GetByID(ctx, id)
}
func (m *mockWorkRepository) Update(ctx context.Context, entity *work.Work) error {
args := m.Called(ctx, entity)
return args.Error(0)
}
func (m *mockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
return m.Update(ctx, entity)
}
func (m *mockWorkRepository) Delete(ctx context.Context, id uint) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *mockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
return m.Delete(ctx, id)
}
func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
panic("not implemented")
}
func (m *mockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) {
panic("not implemented")
}
func (m *mockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { panic("not implemented") }
func (m *mockWorkRepository) Count(ctx context.Context) (int64, error) {
args := m.Called(ctx)
return args.Get(0).(int64), args.Error(1)
}
func (m *mockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
panic("not implemented")
}
func (m *mockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) {
return m.GetByID(ctx, id)
}
func (m *mockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) {
panic("not implemented")
}
func (m *mockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) {
args := m.Called(ctx, id)
return args.Bool(0), args.Error(1)
}
func (m *mockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil }
func (m *mockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
return fn(nil)
}
func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) {
panic("not implemented")
}
func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) {
panic("not implemented")
}
func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) {
panic("not implemented")
}
func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
panic("not implemented")
}
func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*work.Work), args.Error(1)
}
func (m *mockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
panic("not implemented")
}
func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
args := m.Called(ctx, workID, authorID)
return args.Bool(0), args.Error(1)
}

View File

@ -3,6 +3,7 @@ package app
import ( import (
"tercul/internal/app/analytics" "tercul/internal/app/analytics"
"tercul/internal/app/author" "tercul/internal/app/author"
"tercul/internal/app/book"
"tercul/internal/app/bookmark" "tercul/internal/app/bookmark"
"tercul/internal/app/category" "tercul/internal/app/category"
"tercul/internal/app/collection" "tercul/internal/app/collection"
@ -19,9 +20,12 @@ import (
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
) )
import "tercul/internal/app/authz"
// Application is a container for all the application-layer services. // Application is a container for all the application-layer services.
type Application struct { type Application struct {
Author *author.Service Author *author.Service
Book *book.Service
Bookmark *bookmark.Service Bookmark *bookmark.Service
Category *category.Service Category *category.Service
Collection *collection.Service Collection *collection.Service
@ -32,27 +36,31 @@ type Application struct {
User *user.Service User *user.Service
Localization *localization.Service Localization *localization.Service
Auth *auth.Service Auth *auth.Service
Authz *authz.Service
Work *work.Service Work *work.Service
Analytics analytics.Service Analytics analytics.Service
} }
func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application { func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application {
jwtManager := platform_auth.NewJWTManager() jwtManager := platform_auth.NewJWTManager()
authzService := authz.NewService(repos.Work, repos.Translation)
authorService := author.NewService(repos.Author) authorService := author.NewService(repos.Author)
bookService := book.NewService(repos.Book, authzService)
bookmarkService := bookmark.NewService(repos.Bookmark) bookmarkService := bookmark.NewService(repos.Bookmark)
categoryService := category.NewService(repos.Category) categoryService := category.NewService(repos.Category)
collectionService := collection.NewService(repos.Collection) collectionService := collection.NewService(repos.Collection)
commentService := comment.NewService(repos.Comment) commentService := comment.NewService(repos.Comment, authzService)
likeService := like.NewService(repos.Like) likeService := like.NewService(repos.Like)
tagService := tag.NewService(repos.Tag) tagService := tag.NewService(repos.Tag)
translationService := translation.NewService(repos.Translation) translationService := translation.NewService(repos.Translation, authzService)
userService := user.NewService(repos.User) userService := user.NewService(repos.User, authzService)
localizationService := localization.NewService(repos.Localization) localizationService := localization.NewService(repos.Localization)
authService := auth.NewService(repos.User, jwtManager) authService := auth.NewService(repos.User, jwtManager)
workService := work.NewService(repos.Work, searchClient) workService := work.NewService(repos.Work, searchClient, authzService)
return &Application{ return &Application{
Author: authorService, Author: authorService,
Book: bookService,
Bookmark: bookmarkService, Bookmark: bookmarkService,
Category: categoryService, Category: categoryService,
Collection: collectionService, Collection: collectionService,
@ -63,6 +71,7 @@ func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, a
User: userService, User: userService,
Localization: localizationService, Localization: localizationService,
Auth: authService, Auth: authService,
Authz: authzService,
Work: workService, Work: workService,
Analytics: analyticsService, Analytics: analyticsService,
} }

188
internal/app/authz/authz.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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),
}
}

View File

@ -2,17 +2,27 @@ package comment
import ( import (
"context" "context"
"errors"
"fmt"
"tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"gorm.io/gorm"
) )
// CommentCommands contains the command handlers for the comment aggregate. // CommentCommands contains the command handlers for the comment aggregate.
type CommentCommands struct { type CommentCommands struct {
repo domain.CommentRepository repo domain.CommentRepository
authzSvc *authz.Service
} }
// NewCommentCommands creates a new CommentCommands handler. // NewCommentCommands creates a new CommentCommands handler.
func NewCommentCommands(repo domain.CommentRepository) *CommentCommands { func NewCommentCommands(repo domain.CommentRepository, authzSvc *authz.Service) *CommentCommands {
return &CommentCommands{repo: repo} return &CommentCommands{
repo: repo,
authzSvc: authzSvc,
}
} }
// CreateCommentInput represents the input for creating a new comment. // CreateCommentInput represents the input for creating a new comment.
@ -46,12 +56,29 @@ type UpdateCommentInput struct {
Text string Text string
} }
// UpdateComment updates an existing comment. // UpdateComment updates an existing comment after an authorization check.
func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) { func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) {
userID, ok := platform_auth.GetUserIDFromContext(ctx)
if !ok {
return nil, domain.ErrUnauthorized
}
comment, err := c.repo.GetByID(ctx, input.ID) comment, err := c.repo.GetByID(ctx, input.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, input.ID)
}
return nil, err
}
can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment) // Using CanDeleteComment for editing as well
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !can {
return nil, domain.ErrForbidden
}
comment.Text = input.Text comment.Text = input.Text
err = c.repo.Update(ctx, comment) err = c.repo.Update(ctx, comment)
if err != nil { if err != nil {
@ -60,7 +87,28 @@ func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateComment
return comment, nil return comment, nil
} }
// DeleteComment deletes a comment by ID. // DeleteComment deletes a comment by ID after an authorization check.
func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error { func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error {
userID, ok := platform_auth.GetUserIDFromContext(ctx)
if !ok {
return domain.ErrUnauthorized
}
comment, err := c.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, id)
}
return err
}
can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment)
if err != nil {
return err
}
if !can {
return domain.ErrForbidden
}
return c.repo.Delete(ctx, id) return c.repo.Delete(ctx, id)
} }

View File

@ -1,6 +1,9 @@
package comment package comment
import "tercul/internal/domain" import (
"tercul/internal/app/authz"
"tercul/internal/domain"
)
// Service is the application service for the comment aggregate. // Service is the application service for the comment aggregate.
type Service struct { type Service struct {
@ -9,9 +12,9 @@ type Service struct {
} }
// NewService creates a new comment Service. // NewService creates a new comment Service.
func NewService(repo domain.CommentRepository) *Service { func NewService(repo domain.CommentRepository, authzSvc *authz.Service) *Service {
return &Service{ return &Service{
Commands: NewCommentCommands(repo), Commands: NewCommentCommands(repo, authzSvc),
Queries: NewCommentQueries(repo), Queries: NewCommentQueries(repo),
} }
} }

View File

@ -2,17 +2,27 @@ package translation
import ( import (
"context" "context"
"errors"
"fmt"
"tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"gorm.io/gorm"
) )
// TranslationCommands contains the command handlers for the translation aggregate. // TranslationCommands contains the command handlers for the translation aggregate.
type TranslationCommands struct { type TranslationCommands struct {
repo domain.TranslationRepository repo domain.TranslationRepository
authzSvc *authz.Service
} }
// NewTranslationCommands creates a new TranslationCommands handler. // NewTranslationCommands creates a new TranslationCommands handler.
func NewTranslationCommands(repo domain.TranslationRepository) *TranslationCommands { func NewTranslationCommands(repo domain.TranslationRepository, authzSvc *authz.Service) *TranslationCommands {
return &TranslationCommands{repo: repo} return &TranslationCommands{
repo: repo,
authzSvc: authzSvc,
}
} }
// CreateTranslationInput represents the input for creating a new translation. // CreateTranslationInput represents the input for creating a new translation.
@ -60,10 +70,27 @@ type UpdateTranslationInput struct {
// UpdateTranslation updates an existing translation. // UpdateTranslation updates an existing translation.
func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input UpdateTranslationInput) (*domain.Translation, error) { 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 { if err != nil {
return nil, err 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.Title = input.Title
translation.Content = input.Content translation.Content = input.Content
translation.Description = input.Description translation.Description = input.Description
@ -78,5 +105,13 @@ func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input Updat
// DeleteTranslation deletes a translation by ID. // DeleteTranslation deletes a translation by ID.
func (c *TranslationCommands) DeleteTranslation(ctx context.Context, id uint) error { 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) return c.repo.Delete(ctx, id)
} }

View File

@ -1,6 +1,9 @@
package translation package translation
import "tercul/internal/domain" import (
"tercul/internal/app/authz"
"tercul/internal/domain"
)
// Service is the application service for the translation aggregate. // Service is the application service for the translation aggregate.
type Service struct { type Service struct {
@ -9,9 +12,9 @@ type Service struct {
} }
// NewService creates a new translation Service. // NewService creates a new translation Service.
func NewService(repo domain.TranslationRepository) *Service { func NewService(repo domain.TranslationRepository, authzSvc *authz.Service) *Service {
return &Service{ return &Service{
Commands: NewTranslationCommands(repo), Commands: NewTranslationCommands(repo, authzSvc),
Queries: NewTranslationQueries(repo), Queries: NewTranslationQueries(repo),
} }
} }

View File

@ -2,17 +2,27 @@ package user
import ( import (
"context" "context"
"errors"
"fmt"
"tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"gorm.io/gorm"
) )
// UserCommands contains the command handlers for the user aggregate. // UserCommands contains the command handlers for the user aggregate.
type UserCommands struct { type UserCommands struct {
repo domain.UserRepository repo domain.UserRepository
authzSvc *authz.Service
} }
// NewUserCommands creates a new UserCommands handler. // NewUserCommands creates a new UserCommands handler.
func NewUserCommands(repo domain.UserRepository) *UserCommands { func NewUserCommands(repo domain.UserRepository, authzSvc *authz.Service) *UserCommands {
return &UserCommands{repo: repo} return &UserCommands{
repo: repo,
authzSvc: authzSvc,
}
} }
// CreateUserInput represents the input for creating a new user. // CreateUserInput represents the input for creating a new user.
@ -44,25 +54,92 @@ func (c *UserCommands) CreateUser(ctx context.Context, input CreateUserInput) (*
// UpdateUserInput represents the input for updating an existing user. // UpdateUserInput represents the input for updating an existing user.
type UpdateUserInput struct { type UpdateUserInput struct {
ID uint ID uint
Username string Username *string
Email string Email *string
FirstName string Password *string
LastName string FirstName *string
Role domain.UserRole LastName *string
DisplayName *string
Bio *string
AvatarURL *string
Role *domain.UserRole
Verified *bool
Active *bool
CountryID *uint
CityID *uint
AddressID *uint
} }
// UpdateUser updates an existing user. // UpdateUser updates an existing user.
func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*domain.User, error) { func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*domain.User, error) {
user, err := c.repo.GetByID(ctx, input.ID) actorID, ok := platform_auth.GetUserIDFromContext(ctx)
if !ok {
return nil, domain.ErrUnauthorized
}
can, err := c.authzSvc.CanUpdateUser(ctx, actorID, input.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
user.Username = input.Username if !can {
user.Email = input.Email return nil, domain.ErrForbidden
user.FirstName = input.FirstName }
user.LastName = input.LastName
user.Role = input.Role user, err := c.repo.GetByID(ctx, input.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("%w: user with id %d not found", domain.ErrNotFound, input.ID)
}
return nil, err
}
// Apply partial updates
if input.Username != nil {
user.Username = *input.Username
}
if input.Email != nil {
user.Email = *input.Email
}
if input.Password != nil {
if err := user.SetPassword(*input.Password); err != nil {
return nil, err
}
}
if input.FirstName != nil {
user.FirstName = *input.FirstName
}
if input.LastName != nil {
user.LastName = *input.LastName
}
if input.DisplayName != nil {
user.DisplayName = *input.DisplayName
}
if input.Bio != nil {
user.Bio = *input.Bio
}
if input.AvatarURL != nil {
user.AvatarURL = *input.AvatarURL
}
if input.Role != nil {
user.Role = *input.Role
}
if input.Verified != nil {
user.Verified = *input.Verified
}
if input.Active != nil {
user.Active = *input.Active
}
if input.CountryID != nil {
user.CountryID = input.CountryID
}
if input.CityID != nil {
user.CityID = input.CityID
}
if input.AddressID != nil {
user.AddressID = input.AddressID
}
err = c.repo.Update(ctx, user) err = c.repo.Update(ctx, user)
if err != nil { if err != nil {
return nil, err return nil, err
@ -72,5 +149,18 @@ func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*
// DeleteUser deletes a user by ID. // DeleteUser deletes a user by ID.
func (c *UserCommands) DeleteUser(ctx context.Context, id uint) error { func (c *UserCommands) DeleteUser(ctx context.Context, id uint) error {
actorID, ok := platform_auth.GetUserIDFromContext(ctx)
if !ok {
return domain.ErrUnauthorized
}
can, err := c.authzSvc.CanUpdateUser(ctx, actorID, id) // Re-using CanUpdateUser for deletion
if err != nil {
return err
}
if !can {
return domain.ErrForbidden
}
return c.repo.Delete(ctx, id) return c.repo.Delete(ctx, id)
} }

View File

@ -0,0 +1,102 @@
package user
import (
"context"
"testing"
"tercul/internal/app/authz"
"tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type UserCommandsSuite struct {
suite.Suite
repo *mockUserRepository
authzSvc *authz.Service
commands *UserCommands
}
func (s *UserCommandsSuite) SetupTest() {
s.repo = &mockUserRepository{}
workRepo := &mockWorkRepoForUserTests{}
s.authzSvc = authz.NewService(workRepo, 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
}

View File

@ -0,0 +1,32 @@
package user
import (
"context"
"tercul/internal/domain"
)
type mockUserRepository struct {
domain.UserRepository
createFunc func(ctx context.Context, user *domain.User) error
updateFunc func(ctx context.Context, user *domain.User) error
getByIDFunc func(ctx context.Context, id uint) (*domain.User, error)
}
func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error {
if m.createFunc != nil {
return m.createFunc(ctx, user)
}
return nil
}
func (m *mockUserRepository) Update(ctx context.Context, user *domain.User) error {
if m.updateFunc != nil {
return m.updateFunc(ctx, user)
}
return nil
}
func (m *mockUserRepository) GetByID(ctx context.Context, id uint) (*domain.User, error) {
if m.getByIDFunc != nil {
return m.getByIDFunc(ctx, id)
}
return &domain.User{BaseModel: domain.BaseModel{ID: id}}, nil
}

View File

@ -1,6 +1,9 @@
package user package user
import "tercul/internal/domain" import (
"tercul/internal/app/authz"
"tercul/internal/domain"
)
// Service is the application service for the user aggregate. // Service is the application service for the user aggregate.
type Service struct { type Service struct {
@ -9,9 +12,9 @@ type Service struct {
} }
// NewService creates a new user Service. // NewService creates a new user Service.
func NewService(repo domain.UserRepository) *Service { func NewService(repo domain.UserRepository, authzSvc *authz.Service) *Service {
return &Service{ return &Service{
Commands: NewUserCommands(repo), Commands: NewUserCommands(repo, authzSvc),
Queries: NewUserQueries(repo), Queries: NewUserQueries(repo),
} }
} }

View File

@ -0,0 +1,71 @@
package user
import (
"context"
"tercul/internal/domain"
"tercul/internal/domain/work"
"gorm.io/gorm"
)
type mockWorkRepoForUserTests struct{}
func (m *mockWorkRepoForUserTests) Create(ctx context.Context, entity *work.Work) error { return nil }
func (m *mockWorkRepoForUserTests) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
return nil
}
func (m *mockWorkRepoForUserTests) GetByID(ctx context.Context, id uint) (*work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) Update(ctx context.Context, entity *work.Work) error { return nil }
func (m *mockWorkRepoForUserTests) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
return nil
}
func (m *mockWorkRepoForUserTests) Delete(ctx context.Context, id uint) error { return nil }
func (m *mockWorkRepoForUserTests) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil }
func (m *mockWorkRepoForUserTests) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) ListAll(ctx context.Context) ([]work.Work, error) { return nil, nil }
func (m *mockWorkRepoForUserTests) Count(ctx context.Context) (int64, error) { return 0, nil }
func (m *mockWorkRepoForUserTests) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
return 0, nil
}
func (m *mockWorkRepoForUserTests) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) Exists(ctx context.Context, id uint) (bool, error) { return false, nil }
func (m *mockWorkRepoForUserTests) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil }
func (m *mockWorkRepoForUserTests) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
return fn(nil)
}
func (m *mockWorkRepoForUserTests) FindByTitle(ctx context.Context, title string) ([]work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
return nil, nil
}
func (m *mockWorkRepoForUserTests) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
return false, nil
}

View File

@ -3,21 +3,29 @@ package work
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"tercul/internal/app/authz"
"tercul/internal/domain"
"tercul/internal/domain/search" "tercul/internal/domain/search"
"tercul/internal/domain/work" "tercul/internal/domain/work"
platform_auth "tercul/internal/platform/auth"
"gorm.io/gorm"
) )
// WorkCommands contains the command handlers for the work aggregate. // WorkCommands contains the command handlers for the work aggregate.
type WorkCommands struct { type WorkCommands struct {
repo work.WorkRepository repo work.WorkRepository
searchClient search.SearchClient searchClient search.SearchClient
authzSvc *authz.Service
} }
// NewWorkCommands creates a new WorkCommands handler. // NewWorkCommands creates a new WorkCommands handler.
func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient) *WorkCommands { func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *WorkCommands {
return &WorkCommands{ return &WorkCommands{
repo: repo, repo: repo,
searchClient: searchClient, searchClient: searchClient,
authzSvc: authzSvc,
} }
} }
@ -44,21 +52,44 @@ func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.W
return work, nil return work, nil
} }
// UpdateWork updates an existing work. // UpdateWork updates an existing work after performing an authorization check.
func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error { func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
if work == nil { if work == nil {
return errors.New("work cannot be nil") return fmt.Errorf("%w: work cannot be nil", domain.ErrValidation)
} }
if work.ID == 0 { if work.ID == 0 {
return errors.New("work ID cannot be zero") return fmt.Errorf("%w: work ID cannot be zero", domain.ErrValidation)
} }
userID, ok := platform_auth.GetUserIDFromContext(ctx)
if !ok {
return domain.ErrUnauthorized
}
existingWork, err := c.repo.GetByID(ctx, work.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, work.ID)
}
return fmt.Errorf("failed to get work for authorization: %w", err)
}
can, err := c.authzSvc.CanEditWork(ctx, userID, existingWork)
if err != nil {
return err
}
if !can {
return domain.ErrForbidden
}
if work.Title == "" { if work.Title == "" {
return errors.New("work title cannot be empty") return fmt.Errorf("%w: work title cannot be empty", domain.ErrValidation)
} }
if work.Language == "" { if work.Language == "" {
return errors.New("work language cannot be empty") return fmt.Errorf("%w: work language cannot be empty", domain.ErrValidation)
} }
err := c.repo.Update(ctx, work)
err = c.repo.Update(ctx, work)
if err != nil { if err != nil {
return err return err
} }
@ -66,11 +97,36 @@ func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
return c.searchClient.IndexWork(ctx, work, "") return c.searchClient.IndexWork(ctx, work, "")
} }
// DeleteWork deletes a work by ID. // DeleteWork deletes a work by ID after performing an authorization check.
func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error { func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
if id == 0 { if id == 0 {
return errors.New("invalid work ID") return fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
} }
userID, ok := platform_auth.GetUserIDFromContext(ctx)
if !ok {
return domain.ErrUnauthorized
}
existingWork, err := c.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, id)
}
return fmt.Errorf("failed to get work for authorization: %w", err)
}
can, err := c.authzSvc.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) return c.repo.Delete(ctx, id)
} }

View File

@ -5,8 +5,10 @@ import (
"errors" "errors"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
workdomain "tercul/internal/domain/work" workdomain "tercul/internal/domain/work"
platform_auth "tercul/internal/platform/auth"
"testing" "testing"
) )
@ -14,13 +16,15 @@ type WorkCommandsSuite struct {
suite.Suite suite.Suite
repo *mockWorkRepository repo *mockWorkRepository
searchClient *mockSearchClient searchClient *mockSearchClient
authzSvc *authz.Service
commands *WorkCommands commands *WorkCommands
} }
func (s *WorkCommandsSuite) SetupTest() { func (s *WorkCommandsSuite) SetupTest() {
s.repo = &mockWorkRepository{} s.repo = &mockWorkRepository{}
s.searchClient = &mockSearchClient{} s.searchClient = &mockSearchClient{}
s.commands = NewWorkCommands(s.repo, s.searchClient) s.authzSvc = authz.NewService(s.repo, nil)
s.commands = NewWorkCommands(s.repo, s.searchClient, s.authzSvc)
} }
func TestWorkCommandsSuite(t *testing.T) { func TestWorkCommandsSuite(t *testing.T) {
@ -60,9 +64,18 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() {
} }
func (s *WorkCommandsSuite) TestUpdateWork_Success() { func (s *WorkCommandsSuite) TestUpdateWork_Success() {
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1 work.ID = 1
err := s.commands.UpdateWork(context.Background(), work)
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) {
return work, nil
}
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
return true, nil
}
err := s.commands.UpdateWork(ctx, work)
assert.NoError(s.T(), err) assert.NoError(s.T(), err)
} }
@ -102,7 +115,18 @@ func (s *WorkCommandsSuite) TestUpdateWork_RepoError() {
} }
func (s *WorkCommandsSuite) TestDeleteWork_Success() { func (s *WorkCommandsSuite) TestDeleteWork_Success() {
err := s.commands.DeleteWork(context.Background(), 1) ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) {
return work, nil
}
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
return true, nil
}
err := s.commands.DeleteWork(ctx, 1)
assert.NoError(s.T(), err) assert.NoError(s.T(), err)
} }

View File

@ -18,6 +18,14 @@ type mockWorkRepository struct {
findByAuthorFunc func(ctx context.Context, authorID uint) ([]work.Work, error) findByAuthorFunc func(ctx context.Context, authorID uint) ([]work.Work, error)
findByCategoryFunc func(ctx context.Context, categoryID uint) ([]work.Work, error) findByCategoryFunc func(ctx context.Context, categoryID uint) ([]work.Work, error)
findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error)
isAuthorFunc func(ctx context.Context, workID uint, authorID uint) (bool, error)
}
func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
if m.isAuthorFunc != nil {
return m.isAuthorFunc(ctx, workID, authorID)
}
return false, nil
} }
func (m *mockWorkRepository) Create(ctx context.Context, work *work.Work) error { func (m *mockWorkRepository) Create(ctx context.Context, work *work.Work) error {
@ -42,7 +50,7 @@ func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work,
if m.getByIDFunc != nil { if m.getByIDFunc != nil {
return m.getByIDFunc(ctx, id) return m.getByIDFunc(ctx, id)
} }
return nil, nil return &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}, nil
} }
func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
if m.listFunc != nil { if m.listFunc != nil {

View File

@ -1,6 +1,7 @@
package work package work
import ( import (
"tercul/internal/app/authz"
"tercul/internal/domain/search" "tercul/internal/domain/search"
"tercul/internal/domain/work" "tercul/internal/domain/work"
) )
@ -12,9 +13,9 @@ type Service struct {
} }
// NewService creates a new work Service. // NewService creates a new work Service.
func NewService(repo work.WorkRepository, searchClient search.SearchClient) *Service { func NewService(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *Service {
return &Service{ return &Service{
Commands: NewWorkCommands(repo, searchClient), Commands: NewWorkCommands(repo, searchClient, authzSvc),
Queries: NewWorkQueries(repo), Queries: NewWorkQueries(repo),
} }
} }

View File

@ -120,6 +120,21 @@ func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*wor
return r.FindWithPreload(ctx, []string{"Translations"}, id) return r.FindWithPreload(ctx, []string{"Translations"}, id)
} }
// IsAuthor checks if a user is an author of a work.
// Note: This assumes a direct relationship between user ID and author ID,
// which may need to be revised based on the actual domain model.
func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).
Table("work_authors").
Where("work_id = ? AND author_id = ?", workID, authorID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// ListWithTranslations lists works with their translations // ListWithTranslations lists works with their translations
func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
if page < 1 { if page < 1 {

20
internal/domain/errors.go Normal file
View File

@ -0,0 +1,20 @@
package domain
import "errors"
var (
// ErrNotFound indicates that a requested resource was not found.
ErrNotFound = errors.New("not found")
// ErrUnauthorized indicates that the user is not authenticated.
ErrUnauthorized = errors.New("unauthorized")
// ErrForbidden indicates that the user is authenticated but not authorized to perform the action.
ErrForbidden = errors.New("forbidden")
// ErrValidation indicates that the input failed validation.
ErrValidation = errors.New("validation failed")
// ErrConflict indicates a conflict with the current state of the resource (e.g., duplicate).
ErrConflict = errors.New("conflict")
)

View File

@ -14,4 +14,5 @@ type WorkRepository interface {
FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error)
GetWithTranslations(ctx context.Context, id uint) (*Work, error) GetWithTranslations(ctx context.Context, id uint) (*Work, error)
ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error)
IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error)
} }

View File

@ -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}
}

View File

@ -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{})
}

View File

@ -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))
})
}

View File

@ -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)
}

View File

@ -88,27 +88,29 @@ func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler {
func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler { func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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") authHeader := r.Header.Get("Authorization")
if authHeader != "" { if authHeader == "" {
tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader) next.ServeHTTP(w, r)
if err == nil { return
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))
} }
// Continue without authentication tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader)
next.ServeHTTP(w, r) 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} claims := &Claims{UserID: userID}
return context.WithValue(ctx, ClaimsContextKey, claims) return context.WithValue(ctx, ClaimsContextKey, claims)
} }
// ContextWithAdminUser adds an admin user to the context for testing purposes.
func ContextWithAdminUser(ctx context.Context, userID uint) context.Context {
claims := &Claims{
UserID: userID,
Role: "admin",
}
return context.WithValue(ctx, ClaimsContextKey, claims)
}

View File

@ -1,32 +1,145 @@
package log package log
import ( import (
"context"
"fmt" "fmt"
"io" "tercul/internal/observability"
"log"
"os" "github.com/rs/zerolog"
"runtime"
"strings"
"time"
) )
// LogLevel represents the severity level of a log message // LogLevel represents the severity level of a log message.
type LogLevel int type LogLevel int
const ( const (
// DebugLevel for detailed troubleshooting // DebugLevel for detailed troubleshooting.
DebugLevel LogLevel = iota DebugLevel LogLevel = iota
// InfoLevel for general operational information // InfoLevel for general operational information.
InfoLevel InfoLevel
// WarnLevel for potentially harmful situations // WarnLevel for potentially harmful situations.
WarnLevel 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 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 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 { func (l LogLevel) String() string {
switch l { switch l {
case DebugLevel: case DebugLevel:
@ -44,192 +157,83 @@ func (l LogLevel) String() string {
} }
} }
// Field represents a key-value pair for structured logging // Debug logs a message at debug level.
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
func (l *Logger) Debug(msg string, fields ...Field) { 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) { 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) { 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) { 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) { func (l *Logger) Fatal(msg string, fields ...Field) {
if l.level <= FatalLevel { l.log(FatalLevel, msg, fields...)
l.log(FatalLevel, msg, fields...)
os.Exit(1)
}
} }
// 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) { func (l *Logger) log(level LogLevel, msg string, fields ...Field) {
timestamp := time.Now().Format(time.RFC3339) var event *zerolog.Event
switch level {
// Get caller information case DebugLevel:
_, file, line, ok := runtime.Caller(2) event = l.Logger.Debug()
caller := "unknown" case InfoLevel:
if ok { event = l.Logger.Info()
parts := strings.Split(file, "/") case WarnLevel:
if len(parts) > 2 { event = l.Logger.Warn()
caller = fmt.Sprintf("%s:%d", parts[len(parts)-1], line) case ErrorLevel:
} else { event = l.Logger.Error()
caller = fmt.Sprintf("%s:%d", file, line) case FatalLevel:
} event = l.Logger.Fatal()
default:
event = l.Logger.Info()
} }
// Format fields for _, f := range fields {
allFields := append(l.fields, fields...) event.Interface(f.Key, f.Value)
fieldStr := ""
for _, field := range allFields {
fieldStr += fmt.Sprintf(" %s=%v", field.Key, field.Value)
} }
event.Msg(msg)
}
// Format context // WithFields returns a new logger with the given fields added.
contextStr := "" func (l *Logger) WithFields(fields ...Field) *Logger {
for k, v := range l.context { sublogger := l.With().Logger()
contextStr += fmt.Sprintf(" %s=%v", k, v) for _, f := range fields {
} sublogger = sublogger.With().Interface(f.Key, f.Value).Logger()
// 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)
} }
return &Logger{&observability.Logger{&sublogger}}
} }
// Global logger instance func (l *Logger) WithContext(ctx map[string]interface{}) *Logger {
var defaultLogger = New(InfoLevel, os.Stdout) // 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.
// SetDefaultLogger sets the global logger instance zlog := l.Logger.With().Logger()
func SetDefaultLogger(logger *Logger) { for k, v := range ctx {
defaultLogger = logger zlog = zlog.With().Interface(k, v).Logger()
}
return &Logger{&observability.Logger{&zlog}}
} }
// SetDefaultLevel sets the log level for the default logger func (l *Logger) SetLevel(level LogLevel) {
func SetDefaultLevel(level LogLevel) { // This now controls the global log level.
defaultLogger.SetLevel(level) SetDefaultLevel(level)
} }
// LogDebug logs a message at debug level using the default logger // Fmt versions for simple string formatting
func LogDebug(msg string, fields ...Field) { func LogInfof(format string, v ...interface{}) {
defaultLogger.Debug(msg, fields...) log(InfoLevel, fmt.Sprintf(format, v...))
} }
// LogInfo logs a message at info level using the default logger func LogErrorf(format string, v ...interface{}) {
func LogInfo(msg string, fields ...Field) { log(ErrorLevel, fmt.Sprintf(format, v...))
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)
} }

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -2,45 +2,8 @@ package testutil
import ( import (
"context" "context"
graph "tercul/internal/adapters/graphql"
"tercul/internal/app"
"tercul/internal/app/localization"
"tercul/internal/app/work"
"tercul/internal/domain"
domain_localization "tercul/internal/domain/localization"
domain_work "tercul/internal/domain/work"
"github.com/stretchr/testify/suite"
) )
// SimpleTestSuite provides a minimal test environment with just the essentials
type SimpleTestSuite struct {
suite.Suite
WorkRepo *MockWorkRepository
WorkService *work.Service
MockSearchClient *MockSearchClient
}
// MockSearchClient is a mock implementation of the search.SearchClient interface.
type MockSearchClient struct{}
// IndexWork is the mock implementation of the IndexWork method.
func (m *MockSearchClient) IndexWork(ctx context.Context, work *domain_work.Work, pipeline string) error {
return nil
}
// SetupSuite sets up the test suite
func (s *SimpleTestSuite) SetupSuite() {
s.WorkRepo = NewMockWorkRepository()
s.MockSearchClient = &MockSearchClient{}
s.WorkService = work.NewService(s.WorkRepo, s.MockSearchClient)
}
// SetupTest resets test data for each test
func (s *SimpleTestSuite) SetupTest() {
s.WorkRepo = NewMockWorkRepository()
}
// MockLocalizationRepository is a mock implementation of the localization repository. // MockLocalizationRepository is a mock implementation of the localization repository.
type MockLocalizationRepository struct{} type MockLocalizationRepository struct{}
@ -60,33 +23,3 @@ func (m *MockLocalizationRepository) GetTranslations(ctx context.Context, keys [
func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
return "This is a mock biography.", nil return "This is a mock biography.", nil
} }
// GetResolver returns a minimal GraphQL resolver for testing
func (s *SimpleTestSuite) GetResolver() *graph.Resolver {
var mockLocalizationRepo domain_localization.LocalizationRepository = &MockLocalizationRepository{}
localizationService := localization.NewService(mockLocalizationRepo)
return &graph.Resolver{
App: &app.Application{
Work: s.WorkService,
Localization: localizationService,
},
}
}
// CreateTestWork creates a test work with optional content
func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) *domain_work.Work {
work := &domain_work.Work{
Title: title,
TranslatableModel: domain.TranslatableModel{Language: language},
}
// Add work to the mock repository
createdWork, err := s.WorkService.Commands.CreateWork(context.Background(), work)
s.Require().NoError(err)
// If content is provided, we'll need to handle it differently
// since the mock repository doesn't support translations yet
// For now, just return the work
return createdWork
}