mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 00:31:35 +00:00
Merge pull request #9 from SamyRai/feature/observability-stack
feat: Implement observability stack
This commit is contained in:
commit
7b2478273c
17
AGENTS.md
Normal file
17
AGENTS.md
Normal 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.
|
||||
@ -13,6 +13,7 @@ import (
|
||||
graph "tercul/internal/adapters/graphql"
|
||||
dbsql "tercul/internal/data/sql"
|
||||
"tercul/internal/jobs/linguistics"
|
||||
"tercul/internal/observability"
|
||||
"tercul/internal/platform/auth"
|
||||
"tercul/internal/platform/config"
|
||||
"tercul/internal/platform/db"
|
||||
@ -22,6 +23,7 @@ import (
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/playground"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/weaviate/weaviate-go-client/v5/weaviate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@ -54,8 +56,24 @@ func main() {
|
||||
// Load configuration from environment variables
|
||||
config.LoadConfig()
|
||||
|
||||
// Initialize structured logger with appropriate log level
|
||||
log.SetDefaultLevel(log.InfoLevel)
|
||||
// Initialize logger
|
||||
log.Init("tercul-api", config.Cfg.Environment)
|
||||
|
||||
// Initialize OpenTelemetry Tracer Provider
|
||||
tp, err := observability.TracerProvider("tercul-api", config.Cfg.Environment)
|
||||
if err != nil {
|
||||
log.LogFatal("Failed to initialize OpenTelemetry tracer", log.F("error", err))
|
||||
}
|
||||
defer func() {
|
||||
if err := tp.Shutdown(context.Background()); err != nil {
|
||||
log.LogError("Error shutting down tracer provider", log.F("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize Prometheus metrics
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := observability.NewMetrics(reg) // Metrics are registered automatically
|
||||
|
||||
log.LogInfo("Starting Tercul application",
|
||||
log.F("environment", config.Cfg.Environment),
|
||||
log.F("version", "1.0.0"))
|
||||
@ -106,7 +124,7 @@ func main() {
|
||||
}
|
||||
|
||||
jwtManager := auth.NewJWTManager()
|
||||
srv := NewServerWithAuth(resolver, jwtManager)
|
||||
srv := NewServerWithAuth(resolver, jwtManager, metrics)
|
||||
graphQLServer := &http.Server{
|
||||
Addr: config.Cfg.ServerPort,
|
||||
Handler: srv,
|
||||
@ -121,6 +139,13 @@ func main() {
|
||||
}
|
||||
log.LogInfo("GraphQL playground created successfully", log.F("port", config.Cfg.PlaygroundPort))
|
||||
|
||||
// Create metrics server
|
||||
metricsServer := &http.Server{
|
||||
Addr: ":9090",
|
||||
Handler: observability.PrometheusHandler(reg),
|
||||
}
|
||||
log.LogInfo("Metrics server created successfully", log.F("port", ":9090"))
|
||||
|
||||
// Start HTTP servers in goroutines
|
||||
go func() {
|
||||
log.LogInfo("Starting GraphQL server",
|
||||
@ -140,6 +165,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
log.LogInfo("Starting metrics server", log.F("port", ":9090"))
|
||||
if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.LogFatal("Failed to start metrics server", log.F("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the servers
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
@ -161,5 +193,9 @@ func main() {
|
||||
log.F("error", err))
|
||||
}
|
||||
|
||||
if err := metricsServer.Shutdown(ctx); err != nil {
|
||||
log.LogError("Metrics server forced to shutdown", log.F("error", err))
|
||||
}
|
||||
|
||||
log.LogInfo("All servers shutdown successfully")
|
||||
}
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"net/http"
|
||||
"tercul/internal/adapters/graphql"
|
||||
"tercul/internal/observability"
|
||||
"tercul/internal/platform/auth"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
@ -21,18 +22,26 @@ func NewServer(resolver *graphql.Resolver) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// NewServerWithAuth creates a new GraphQL server with authentication middleware
|
||||
func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler {
|
||||
// NewServerWithAuth creates a new GraphQL server with authentication and observability middleware
|
||||
func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics) http.Handler {
|
||||
c := graphql.Config{Resolvers: resolver}
|
||||
c.Directives.Binding = graphql.Binding
|
||||
|
||||
// Create the server with the custom error presenter
|
||||
srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c))
|
||||
srv.SetErrorPresenter(graphql.NewErrorPresenter())
|
||||
|
||||
// Apply authentication middleware to GraphQL endpoint
|
||||
authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv)
|
||||
// Create a middleware chain
|
||||
var chain http.Handler
|
||||
chain = srv
|
||||
chain = auth.GraphQLAuthMiddleware(jwtManager)(chain)
|
||||
chain = metrics.PrometheusMiddleware(chain)
|
||||
chain = observability.TracingMiddleware(chain)
|
||||
chain = observability.RequestIDMiddleware(chain)
|
||||
|
||||
// Create a mux to handle GraphQL endpoint only (no playground here; served separately in production)
|
||||
// Create a mux to handle GraphQL endpoint
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/query", authHandler)
|
||||
mux.Handle("/query", chain)
|
||||
|
||||
return mux
|
||||
}
|
||||
}
|
||||
36
go.mod
36
go.mod
@ -8,16 +8,23 @@ require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/hibiken/asynq v0.25.1
|
||||
github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc
|
||||
github.com/pemistahl/lingua-go v1.4.0
|
||||
github.com/pressly/goose/v3 v3.25.0
|
||||
github.com/pressly/goose/v3 v3.21.1
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/redis/go-redis/v9 v9.13.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/vektah/gqlparser/v2 v2.5.30
|
||||
github.com/weaviate/weaviate v1.32.6
|
||||
github.com/weaviate/weaviate-go-client/v5 v5.4.1
|
||||
github.com/weaviate/weaviate v1.33.0-rc.1
|
||||
github.com/weaviate/weaviate-go-client/v5 v5.5.0
|
||||
go.opentelemetry.io/otel v1.38.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0
|
||||
go.opentelemetry.io/otel/sdk v1.38.0
|
||||
go.opentelemetry.io/otel/trace v1.38.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
@ -25,13 +32,13 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
ariga.io/atlas-go-sdk v0.5.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.67.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.1 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.17.1 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
@ -43,6 +50,8 @@ require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/analysis v0.23.0 // indirect
|
||||
github.com/go-openapi/errors v0.22.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
@ -60,7 +69,6 @@ require (
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@ -68,18 +76,18 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mfridman/xflag v0.1.0 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
@ -87,6 +95,8 @@ require (
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
@ -105,8 +115,8 @@ require (
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
go.mongodb.org/mongo-driver v1.14.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
@ -118,8 +128,8 @@ require (
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
gonum.org/v1/gonum v0.15.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/grpc v1.73.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
83
go.sum
83
go.sum
@ -1,5 +1,3 @@
|
||||
ariga.io/atlas-go-sdk v0.5.1 h1:I3iRshdwSODVWwMS4zvXObnfCQrEOY8BLRwynJQA+qE=
|
||||
ariga.io/atlas-go-sdk v0.5.1/go.mod h1:UZXG++2NQCDAetk+oIitYIGpL/VsBVCt4GXbtWBA/GY=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
@ -19,10 +17,10 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/ClickHouse/ch-go v0.67.0 h1:18MQF6vZHj+4/hTRaK7JbS/TIzn4I55wC+QzO24uiqc=
|
||||
github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 h1:PbwsHBgqXRydU7jKULD1C8CHmifczffvQqmFvltM2W4=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.40.1/go.mod h1:GDzSBLVhladVm8V01aEB36IoBOVLLICfyeuiIp/8Ezc=
|
||||
github.com/ClickHouse/ch-go v0.61.1 h1:j5rx3qnvcnYjhnP1IdXE/vdIRQiqgwAzyqOaasA6QCw=
|
||||
github.com/ClickHouse/ch-go v0.61.1/go.mod h1:myxt/JZgy2BYHFGQqzmaIpbfr5CMbs3YHVULaWQj5YU=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.17.1 h1:ZCmAYWpu75IyEi7+Yrs/uaAjiCGY5wfW5kXo64exkX4=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.17.1/go.mod h1:rkGTvFDTLqLIm0ma+13xmcCfr/08Gvs7KmFt1tgiWHQ=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
@ -47,6 +45,8 @@ github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:W
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@ -64,6 +64,7 @@ github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWH
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
@ -100,6 +101,7 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@ -178,6 +180,7 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe
|
||||
github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
|
||||
github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
|
||||
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
@ -245,8 +248,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc h1:Zvn/U2151AlhFbOIIZivbnpvExjD/8rlQsO/RaNJQw0=
|
||||
@ -282,14 +283,17 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
|
||||
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
|
||||
github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
|
||||
github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs=
|
||||
github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
@ -298,6 +302,8 @@ github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
@ -322,9 +328,15 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
|
||||
github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ=
|
||||
github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
@ -340,8 +352,11 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
|
||||
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
@ -381,10 +396,10 @@ github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsL
|
||||
github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw=
|
||||
github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
|
||||
github.com/weaviate/weaviate v1.32.6 h1:N0MRjuqZT9l2un4xFeV4fXZ9dkLbqrijC5JIfr759Os=
|
||||
github.com/weaviate/weaviate v1.32.6/go.mod h1:hzzhAOYxgKe+B2jxZJtaWMIdElcXXn+RQyQ7ccQORNg=
|
||||
github.com/weaviate/weaviate-go-client/v5 v5.4.1 h1:hfKocGPe11IUr4XsLp3q9hJYck0I2yIHGlFBpLqb/F4=
|
||||
github.com/weaviate/weaviate-go-client/v5 v5.4.1/go.mod h1:l72EnmCLj9LCQkR8S7nN7Y1VqGMmL3Um8exhFkMmfwk=
|
||||
github.com/weaviate/weaviate v1.33.0-rc.1 h1:3Kol9BmA9JOj1I4vOkz0tu4A87K3dKVAnr8k8DMhBs8=
|
||||
github.com/weaviate/weaviate v1.33.0-rc.1/go.mod h1:MmHF/hZDL0I8j0qAMEa9/TS4ISLaYlIp1Bc3e/n3eUU=
|
||||
github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4=
|
||||
github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
@ -411,16 +426,18 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd
|
||||
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
@ -500,7 +517,9 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
@ -547,8 +566,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
@ -556,8 +575,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
||||
77
internal/adapters/graphql/analytics_service_mock_test.go
Normal file
77
internal/adapters/graphql/analytics_service_mock_test.go
Normal 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)
|
||||
}
|
||||
241
internal/adapters/graphql/book_integration_test.go
Normal file
241
internal/adapters/graphql/book_integration_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
49
internal/adapters/graphql/errors.go
Normal file
49
internal/adapters/graphql/errors.go
Normal 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
@ -2,6 +2,7 @@ package graphql_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -27,7 +28,7 @@ type graphQLTestServer interface {
|
||||
}
|
||||
|
||||
// executeGraphQL executes a GraphQL query against a test server and decodes the response.
|
||||
func executeGraphQL[T any](s graphQLTestServer, query string, variables map[string]interface{}, token *string) (*GraphQLResponse[T], error) {
|
||||
func executeGraphQL[T any](s graphQLTestServer, query string, variables map[string]interface{}, token *string, ctx ...context.Context) (*GraphQLResponse[T], error) {
|
||||
request := GraphQLRequest{
|
||||
Query: query,
|
||||
Variables: variables,
|
||||
@ -38,7 +39,14 @@ func executeGraphQL[T any](s graphQLTestServer, query string, variables map[stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", s.getURL(), bytes.NewBuffer(requestBody))
|
||||
var reqCtx context.Context
|
||||
if len(ctx) > 0 {
|
||||
reqCtx = ctx[0]
|
||||
} else {
|
||||
reqCtx = context.Background()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, "POST", s.getURL(), bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -18,10 +18,12 @@ import (
|
||||
"tercul/internal/app/translation"
|
||||
"tercul/internal/domain"
|
||||
"tercul/internal/domain/work"
|
||||
"tercul/internal/observability"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
"tercul/internal/testutil"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
@ -48,14 +50,21 @@ func (s *GraphQLIntegrationSuite) CreateAuthenticatedUser(username, email string
|
||||
|
||||
// Update user role if necessary
|
||||
user := authResponse.User
|
||||
token := authResponse.Token
|
||||
if user.Role != role {
|
||||
// This part is tricky. There is no UpdateUserRole command.
|
||||
// For a test, I can update the DB directly.
|
||||
s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role)
|
||||
user.Role = role
|
||||
|
||||
// Re-generate token with the new role
|
||||
jwtManager := platform_auth.NewJWTManager()
|
||||
newToken, err := jwtManager.GenerateToken(user)
|
||||
s.Require().NoError(err)
|
||||
token = newToken
|
||||
}
|
||||
|
||||
return user, authResponse.Token
|
||||
return user, token
|
||||
}
|
||||
|
||||
// SetupSuite sets up the test suite
|
||||
@ -64,16 +73,27 @@ func (s *GraphQLIntegrationSuite) SetupSuite() {
|
||||
|
||||
// Create GraphQL server with the test resolver
|
||||
resolver := &graph.Resolver{App: s.App}
|
||||
srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver}))
|
||||
c := graph.Config{Resolvers: resolver}
|
||||
c.Directives.Binding = graph.Binding // Register the binding directive
|
||||
|
||||
// Create the server with the custom error presenter
|
||||
srv := handler.NewDefaultServer(graph.NewExecutableSchema(c))
|
||||
srv.SetErrorPresenter(graph.NewErrorPresenter())
|
||||
|
||||
// Create JWT manager and middleware
|
||||
jwtManager := platform_auth.NewJWTManager()
|
||||
authMiddleware := platform_auth.GraphQLAuthMiddleware(jwtManager)
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := observability.NewMetrics(reg)
|
||||
|
||||
s.server = httptest.NewServer(authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
srv.ServeHTTP(w, r)
|
||||
})))
|
||||
// Create a middleware chain
|
||||
var chain http.Handler
|
||||
chain = srv
|
||||
chain = platform_auth.GraphQLAuthMiddleware(jwtManager)(chain)
|
||||
chain = metrics.PrometheusMiddleware(chain)
|
||||
chain = observability.TracingMiddleware(chain)
|
||||
chain = observability.RequestIDMiddleware(chain)
|
||||
|
||||
s.server = httptest.NewServer(chain)
|
||||
s.client = s.server.Client()
|
||||
}
|
||||
|
||||
@ -225,7 +245,8 @@ func (s *GraphQLIntegrationSuite) TestCreateWork() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[CreateWorkResponse](s, mutation, variables, nil)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
response, err := executeGraphQL[CreateWorkResponse](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
||||
@ -330,7 +351,8 @@ func (s *GraphQLIntegrationSuite) TestCreateWorkValidation() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
|
||||
@ -362,7 +384,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateWorkValidation() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
|
||||
@ -390,7 +413,8 @@ func (s *GraphQLIntegrationSuite) TestCreateAuthorValidation() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
|
||||
@ -423,7 +447,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
|
||||
@ -455,7 +480,8 @@ func (s *GraphQLIntegrationSuite) TestCreateTranslationValidation() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
|
||||
@ -496,7 +522,8 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().NotNil(response.Errors, "GraphQL mutation should return errors")
|
||||
@ -508,6 +535,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() {
|
||||
s.Run("should delete a work", func() {
|
||||
// Arrange
|
||||
work := s.CreateTestWork("Test Work", "en", "Test content")
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
|
||||
// Define the mutation
|
||||
mutation := `
|
||||
@ -522,7 +550,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
||||
@ -540,6 +568,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() {
|
||||
// Arrange
|
||||
createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"})
|
||||
s.Require().NoError(err)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
|
||||
// Define the mutation
|
||||
mutation := `
|
||||
@ -554,7 +583,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
||||
@ -579,6 +608,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
|
||||
TranslatableType: "works",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||
|
||||
// Define the mutation
|
||||
mutation := `
|
||||
@ -593,7 +623,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
|
||||
}
|
||||
|
||||
// Execute the mutation
|
||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
||||
response, err := executeGraphQL[any](s, mutation, variables, &adminToken)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(response)
|
||||
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
||||
|
||||
86
internal/adapters/graphql/like_repo_mock_test.go
Normal file
86
internal/adapters/graphql/like_repo_mock_test.go
Normal 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)
|
||||
}
|
||||
@ -13,7 +13,6 @@ import (
|
||||
"tercul/internal/app/like"
|
||||
"tercul/internal/domain"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
"tercul/internal/testutil"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
@ -23,20 +22,20 @@ import (
|
||||
type LikeResolversUnitSuite struct {
|
||||
suite.Suite
|
||||
resolver *graphql.Resolver
|
||||
mockLikeRepo *testutil.MockLikeRepository
|
||||
mockWorkRepo *testutil.MockWorkRepository
|
||||
mockAnalyticsSvc *testutil.MockAnalyticsService
|
||||
mockLikeRepo *mockLikeRepository
|
||||
mockWorkRepo *mockWorkRepository
|
||||
mockAnalyticsSvc *mockAnalyticsService
|
||||
}
|
||||
|
||||
func (s *LikeResolversUnitSuite) SetupTest() {
|
||||
// 1. Create mock repositories
|
||||
s.mockLikeRepo = new(testutil.MockLikeRepository)
|
||||
s.mockWorkRepo = new(testutil.MockWorkRepository)
|
||||
s.mockAnalyticsSvc = new(testutil.MockAnalyticsService)
|
||||
s.mockLikeRepo = new(mockLikeRepository)
|
||||
s.mockWorkRepo = new(mockWorkRepository)
|
||||
s.mockAnalyticsSvc = new(mockAnalyticsService)
|
||||
|
||||
// 2. Create real services with mock repositories
|
||||
likeService := like.NewService(s.mockLikeRepo)
|
||||
analyticsService := analytics.NewService(s.mockAnalyticsSvc, nil, nil, nil, nil)
|
||||
analyticsService := analytics.NewService(s.mockAnalyticsSvc, nil, nil, s.mockWorkRepo, nil)
|
||||
|
||||
// 3. Create the resolver with the services
|
||||
s.resolver = &graphql.Resolver{
|
||||
|
||||
@ -60,14 +60,25 @@ type Book struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Isbn *string `json:"isbn,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Works []*Work `json:"works,omitempty"`
|
||||
Authors []*Author `json:"authors,omitempty"`
|
||||
Stats *BookStats `json:"stats,omitempty"`
|
||||
Copyright *Copyright `json:"copyright,omitempty"`
|
||||
CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,omitempty"`
|
||||
}
|
||||
|
||||
type BookInput struct {
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Isbn *string `json:"isbn,omitempty"`
|
||||
AuthorIds []string `json:"authorIds,omitempty"`
|
||||
}
|
||||
|
||||
type BookStats struct {
|
||||
ID string `json:"id"`
|
||||
Sales int32 `json:"sales"`
|
||||
|
||||
@ -127,14 +127,25 @@ type Book {
|
||||
id: ID!
|
||||
name: String!
|
||||
language: String!
|
||||
description: String
|
||||
isbn: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
works: [Work!]
|
||||
authors: [Author!]
|
||||
stats: BookStats
|
||||
copyright: Copyright
|
||||
copyrightClaims: [CopyrightClaim!]
|
||||
}
|
||||
|
||||
input BookInput {
|
||||
name: String!
|
||||
language: String!
|
||||
description: String
|
||||
isbn: String
|
||||
authorIds: [ID!]
|
||||
}
|
||||
|
||||
type Collection {
|
||||
id: ID!
|
||||
name: String!
|
||||
@ -453,8 +464,6 @@ type Edge {
|
||||
|
||||
scalar JSON
|
||||
|
||||
directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
|
||||
|
||||
# Queries
|
||||
type Query {
|
||||
# Work queries
|
||||
@ -478,6 +487,10 @@ type Query {
|
||||
offset: Int
|
||||
): [Translation!]!
|
||||
|
||||
# Book queries
|
||||
book(id: ID!): Book
|
||||
books(limit: Int, offset: Int): [Book!]!
|
||||
|
||||
# Author queries
|
||||
author(id: ID!): Author
|
||||
authors(
|
||||
@ -567,6 +580,11 @@ type Mutation {
|
||||
createTranslation(input: TranslationInput!): Translation!
|
||||
updateTranslation(id: ID!, input: TranslationInput!): Translation!
|
||||
deleteTranslation(id: ID!): Boolean!
|
||||
|
||||
# Book mutations
|
||||
createBook(input: BookInput!): Book!
|
||||
updateBook(id: ID!, input: BookInput!): Book!
|
||||
deleteBook(id: ID!): Boolean!
|
||||
|
||||
# Author mutations
|
||||
createAuthor(input: AuthorInput!): Author!
|
||||
@ -618,16 +636,16 @@ type Mutation {
|
||||
|
||||
# Input types
|
||||
input LoginInput {
|
||||
email: String!
|
||||
password: String!
|
||||
email: String! @binding(constraint: "required,email")
|
||||
password: String! @binding(constraint: "required")
|
||||
}
|
||||
|
||||
input RegisterInput {
|
||||
username: String!
|
||||
email: String!
|
||||
password: String!
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
username: String! @binding(constraint: "required,min=3,max=50")
|
||||
email: String! @binding(constraint: "required,email")
|
||||
password: String! @binding(constraint: "required,min=8")
|
||||
firstName: String! @binding(constraint: "required")
|
||||
lastName: String! @binding(constraint: "required")
|
||||
}
|
||||
|
||||
type AuthPayload {
|
||||
@ -636,8 +654,8 @@ type AuthPayload {
|
||||
}
|
||||
|
||||
input WorkInput {
|
||||
name: String!
|
||||
language: String!
|
||||
name: String! @binding(constraint: "required,min=2")
|
||||
language: String! @binding(constraint: "required,len=2")
|
||||
content: String
|
||||
authorIds: [ID!]
|
||||
tagIds: [ID!]
|
||||
@ -645,15 +663,15 @@ input WorkInput {
|
||||
}
|
||||
|
||||
input TranslationInput {
|
||||
name: String!
|
||||
language: String!
|
||||
name: String! @binding(constraint: "required,min=2")
|
||||
language: String! @binding(constraint: "required,len=2")
|
||||
content: String
|
||||
workId: ID!
|
||||
}
|
||||
|
||||
input AuthorInput {
|
||||
name: String!
|
||||
language: String!
|
||||
name: String! @binding(constraint: "required,min=2")
|
||||
language: String! @binding(constraint: "required,len=2")
|
||||
biography: String
|
||||
birthDate: String
|
||||
deathDate: String
|
||||
@ -711,3 +729,5 @@ input ContributionInput {
|
||||
translationId: ID
|
||||
status: ContributionStatus
|
||||
}
|
||||
|
||||
directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION
|
||||
|
||||
@ -11,11 +11,13 @@ import (
|
||||
"tercul/internal/adapters/graphql/model"
|
||||
"tercul/internal/app/auth"
|
||||
"tercul/internal/app/author"
|
||||
"tercul/internal/app/book"
|
||||
"tercul/internal/app/bookmark"
|
||||
"tercul/internal/app/collection"
|
||||
"tercul/internal/app/comment"
|
||||
"tercul/internal/app/like"
|
||||
"tercul/internal/app/translation"
|
||||
"tercul/internal/app/user"
|
||||
"tercul/internal/domain"
|
||||
"tercul/internal/domain/work"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
@ -88,8 +90,8 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*
|
||||
|
||||
// CreateWork is the resolver for the createWork field.
|
||||
func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) {
|
||||
if err := validateWorkInput(input); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Create domain model
|
||||
workModel := &work.Work{
|
||||
@ -131,12 +133,13 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput
|
||||
|
||||
// UpdateWork is the resolver for the updateWork field.
|
||||
func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) {
|
||||
if err := validateWorkInput(input); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
workID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid work ID: %v", err)
|
||||
return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
|
||||
}
|
||||
|
||||
// Create domain model
|
||||
@ -167,7 +170,7 @@ func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input mode
|
||||
func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, error) {
|
||||
workID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid work ID: %v", err)
|
||||
return false, fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
|
||||
}
|
||||
|
||||
err = r.App.Work.Commands.DeleteWork(ctx, uint(workID))
|
||||
@ -180,12 +183,21 @@ func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, err
|
||||
|
||||
// CreateTranslation is the resolver for the createTranslation field.
|
||||
func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) {
|
||||
if err := validateTranslationInput(input); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
can, err := r.App.Authz.CanCreateTranslation(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !can {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
workID, err := strconv.ParseUint(input.WorkID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid work ID: %v", err)
|
||||
return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
|
||||
}
|
||||
|
||||
// Create domain model
|
||||
@ -227,8 +239,8 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr
|
||||
|
||||
// UpdateTranslation is the resolver for the updateTranslation field.
|
||||
func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) {
|
||||
if err := validateTranslationInput(input); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translationID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
@ -274,10 +286,85 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateBook is the resolver for the createBook field.
|
||||
func (r *mutationResolver) CreateBook(ctx context.Context, input model.BookInput) (*model.Book, error) {
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createInput := book.CreateBookInput{
|
||||
Title: input.Name,
|
||||
Description: *input.Description,
|
||||
Language: input.Language,
|
||||
ISBN: input.Isbn,
|
||||
}
|
||||
|
||||
createdBook, err := r.App.Book.Commands.CreateBook(ctx, createInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Book{
|
||||
ID: fmt.Sprintf("%d", createdBook.ID),
|
||||
Name: createdBook.Title,
|
||||
Language: createdBook.Language,
|
||||
Description: &createdBook.Description,
|
||||
Isbn: &createdBook.ISBN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateBook is the resolver for the updateBook field.
|
||||
func (r *mutationResolver) UpdateBook(ctx context.Context, id string, input model.BookInput) (*model.Book, error) {
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bookID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid book ID", domain.ErrValidation)
|
||||
}
|
||||
|
||||
updateInput := book.UpdateBookInput{
|
||||
ID: uint(bookID),
|
||||
Title: &input.Name,
|
||||
Description: input.Description,
|
||||
Language: &input.Language,
|
||||
ISBN: input.Isbn,
|
||||
}
|
||||
|
||||
updatedBook, err := r.App.Book.Commands.UpdateBook(ctx, updateInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Book{
|
||||
ID: id,
|
||||
Name: updatedBook.Title,
|
||||
Language: updatedBook.Language,
|
||||
Description: &updatedBook.Description,
|
||||
Isbn: &updatedBook.ISBN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteBook is the resolver for the deleteBook field.
|
||||
func (r *mutationResolver) DeleteBook(ctx context.Context, id string) (bool, error) {
|
||||
bookID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: invalid book ID", domain.ErrValidation)
|
||||
}
|
||||
|
||||
err = r.App.Book.Commands.DeleteBook(ctx, uint(bookID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateAuthor is the resolver for the createAuthor field.
|
||||
func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) {
|
||||
if err := validateAuthorInput(input); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Call author service
|
||||
createInput := author.CreateAuthorInput{
|
||||
@ -298,8 +385,8 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI
|
||||
|
||||
// UpdateAuthor is the resolver for the updateAuthor field.
|
||||
func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) {
|
||||
if err := validateAuthorInput(input); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
@ -341,7 +428,78 @@ func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, e
|
||||
|
||||
// UpdateUser is the resolver for the updateUser field.
|
||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input model.UserInput) (*model.User, error) {
|
||||
panic(fmt.Errorf("not implemented: UpdateUser - updateUser"))
|
||||
if err := Validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user ID: %v", err)
|
||||
}
|
||||
|
||||
updateInput := user.UpdateUserInput{
|
||||
ID: uint(userID),
|
||||
Username: input.Username,
|
||||
Email: input.Email,
|
||||
Password: input.Password,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
DisplayName: input.DisplayName,
|
||||
Bio: input.Bio,
|
||||
AvatarURL: input.AvatarURL,
|
||||
Verified: input.Verified,
|
||||
Active: input.Active,
|
||||
}
|
||||
|
||||
if input.Role != nil {
|
||||
role := domain.UserRole(input.Role.String())
|
||||
updateInput.Role = &role
|
||||
}
|
||||
|
||||
if input.CountryID != nil {
|
||||
countryID, err := strconv.ParseUint(*input.CountryID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid country ID: %v", err)
|
||||
}
|
||||
uid := uint(countryID)
|
||||
updateInput.CountryID = &uid
|
||||
}
|
||||
if input.CityID != nil {
|
||||
cityID, err := strconv.ParseUint(*input.CityID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid city ID: %v", err)
|
||||
}
|
||||
uid := uint(cityID)
|
||||
updateInput.CityID = &uid
|
||||
}
|
||||
if input.AddressID != nil {
|
||||
addressID, err := strconv.ParseUint(*input.AddressID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address ID: %v", err)
|
||||
}
|
||||
uid := uint(addressID)
|
||||
updateInput.AddressID = &uid
|
||||
}
|
||||
|
||||
updatedUser, err := r.App.User.Commands.UpdateUser(ctx, updateInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to GraphQL model
|
||||
return &model.User{
|
||||
ID: fmt.Sprintf("%d", updatedUser.ID),
|
||||
Username: updatedUser.Username,
|
||||
Email: updatedUser.Email,
|
||||
FirstName: &updatedUser.FirstName,
|
||||
LastName: &updatedUser.LastName,
|
||||
DisplayName: &updatedUser.DisplayName,
|
||||
Bio: &updatedUser.Bio,
|
||||
AvatarURL: &updatedUser.AvatarURL,
|
||||
Role: model.UserRole(updatedUser.Role),
|
||||
Verified: updatedUser.Verified,
|
||||
Active: updatedUser.Active,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteUser is the resolver for the deleteUser field.
|
||||
@ -990,6 +1148,51 @@ func (r *queryResolver) Translations(ctx context.Context, workID string, languag
|
||||
panic(fmt.Errorf("not implemented: Translations - translations"))
|
||||
}
|
||||
|
||||
// Book is the resolver for the book field.
|
||||
func (r *queryResolver) Book(ctx context.Context, id string) (*model.Book, error) {
|
||||
bookID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid book ID", domain.ErrValidation)
|
||||
}
|
||||
|
||||
bookRecord, err := r.App.Book.Queries.Book(ctx, uint(bookID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bookRecord == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &model.Book{
|
||||
ID: fmt.Sprintf("%d", bookRecord.ID),
|
||||
Name: bookRecord.Title,
|
||||
Language: bookRecord.Language,
|
||||
Description: &bookRecord.Description,
|
||||
Isbn: &bookRecord.ISBN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Books is the resolver for the books field.
|
||||
func (r *queryResolver) Books(ctx context.Context, limit *int32, offset *int32) ([]*model.Book, error) {
|
||||
books, err := r.App.Book.Queries.Books(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []*model.Book
|
||||
for _, b := range books {
|
||||
result = append(result, &model.Book{
|
||||
ID: fmt.Sprintf("%d", b.ID),
|
||||
Name: b.Title,
|
||||
Language: b.Language,
|
||||
Description: &b.Description,
|
||||
Isbn: &b.ISBN,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Author is the resolver for the author field.
|
||||
func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, error) {
|
||||
panic(fmt.Errorf("not implemented: Author - author"))
|
||||
@ -1264,63 +1467,3 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type queryResolver struct{ *Resolver }
|
||||
|
||||
// !!! WARNING !!!
|
||||
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
|
||||
// one last chance to move it out of harms way if you want. There are two reasons this happens:
|
||||
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
|
||||
// it when you're done.
|
||||
// - You have helper methods in this file. Move them out to keep these resolver files clean.
|
||||
/*
|
||||
func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) {
|
||||
translationID, err := strconv.ParseUint(obj.ID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid translation ID: %v", err)
|
||||
}
|
||||
|
||||
stats, err := r.App.Analytics.GetOrCreateTranslationStats(ctx, uint(translationID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert domain model to GraphQL model
|
||||
return &model.TranslationStats{
|
||||
ID: fmt.Sprintf("%d", stats.ID),
|
||||
Views: toInt32(stats.Views),
|
||||
Likes: toInt32(stats.Likes),
|
||||
Comments: toInt32(stats.Comments),
|
||||
Shares: toInt32(stats.Shares),
|
||||
ReadingTime: toInt32(int64(stats.ReadingTime)),
|
||||
Sentiment: &stats.Sentiment,
|
||||
}, nil
|
||||
}
|
||||
func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkStats, error) {
|
||||
workID, err := strconv.ParseUint(obj.ID, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid work ID: %v", err)
|
||||
}
|
||||
|
||||
stats, err := r.App.Analytics.GetOrCreateWorkStats(ctx, uint(workID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert domain model to GraphQL model
|
||||
return &model.WorkStats{
|
||||
ID: fmt.Sprintf("%d", stats.ID),
|
||||
Views: toInt32(stats.Views),
|
||||
Likes: toInt32(stats.Likes),
|
||||
Comments: toInt32(stats.Comments),
|
||||
Bookmarks: toInt32(stats.Bookmarks),
|
||||
Shares: toInt32(stats.Shares),
|
||||
TranslationCount: toInt32(stats.TranslationCount),
|
||||
ReadingTime: toInt32(int64(stats.ReadingTime)),
|
||||
Complexity: &stats.Complexity,
|
||||
Sentiment: &stats.Sentiment,
|
||||
}, nil
|
||||
}
|
||||
func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} }
|
||||
func (r *Resolver) Work() WorkResolver { return &workResolver{r} }
|
||||
type translationResolver struct{ *Resolver }
|
||||
type workResolver struct{ *Resolver }
|
||||
*/
|
||||
|
||||
@ -4,54 +4,30 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"tercul/internal/adapters/graphql/model"
|
||||
"tercul/internal/domain"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var ErrValidation = errors.New("validation failed")
|
||||
// The 'validate' variable is declared in binding.go and is used here.
|
||||
|
||||
func validateWorkInput(input model.WorkInput) error {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if len(name) < 3 {
|
||||
return fmt.Errorf("name must be at least 3 characters long")
|
||||
// Validate performs validation on a struct using the validator library.
|
||||
func Validate(s interface{}) error {
|
||||
err := validate.Struct(s)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
|
||||
return fmt.Errorf("name can only contain letters, numbers, and spaces")
|
||||
}
|
||||
if len(input.Language) != 2 {
|
||||
return fmt.Errorf("language must be a 2-character code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAuthorInput(input model.AuthorInput) error {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if len(name) < 3 {
|
||||
return fmt.Errorf("name must be at least 3 characters long")
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
var errorMessages []string
|
||||
for _, err := range validationErrors {
|
||||
// Customize error messages here if needed.
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("field '%s' failed on the '%s' tag", err.Field(), err.Tag()))
|
||||
}
|
||||
return fmt.Errorf("%w: %s", domain.ErrValidation, strings.Join(errorMessages, "; "))
|
||||
}
|
||||
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
|
||||
return fmt.Errorf("name can only contain letters, numbers, and spaces")
|
||||
}
|
||||
if len(input.Language) != 2 {
|
||||
return fmt.Errorf("language must be a 2-character code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTranslationInput(input model.TranslationInput) error {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if len(name) < 3 {
|
||||
return fmt.Errorf("name must be at least 3 characters long")
|
||||
}
|
||||
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
|
||||
return fmt.Errorf("name can only contain letters, numbers, and spaces")
|
||||
}
|
||||
if len(input.Language) != 2 {
|
||||
return fmt.Errorf("language must be a 2-character code")
|
||||
}
|
||||
if input.WorkID == "" {
|
||||
return fmt.Errorf("workId is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// For other unexpected errors, like invalid validation input.
|
||||
return fmt.Errorf("unexpected error during validation: %w", err)
|
||||
}
|
||||
101
internal/adapters/graphql/work_repo_mock_test.go
Normal file
101
internal/adapters/graphql/work_repo_mock_test.go
Normal 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)
|
||||
}
|
||||
@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"tercul/internal/app/analytics"
|
||||
"tercul/internal/app/author"
|
||||
"tercul/internal/app/book"
|
||||
"tercul/internal/app/bookmark"
|
||||
"tercul/internal/app/category"
|
||||
"tercul/internal/app/collection"
|
||||
@ -19,9 +20,12 @@ import (
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
)
|
||||
|
||||
import "tercul/internal/app/authz"
|
||||
|
||||
// Application is a container for all the application-layer services.
|
||||
type Application struct {
|
||||
Author *author.Service
|
||||
Book *book.Service
|
||||
Bookmark *bookmark.Service
|
||||
Category *category.Service
|
||||
Collection *collection.Service
|
||||
@ -32,27 +36,31 @@ type Application struct {
|
||||
User *user.Service
|
||||
Localization *localization.Service
|
||||
Auth *auth.Service
|
||||
Authz *authz.Service
|
||||
Work *work.Service
|
||||
Analytics analytics.Service
|
||||
}
|
||||
|
||||
func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application {
|
||||
jwtManager := platform_auth.NewJWTManager()
|
||||
authzService := authz.NewService(repos.Work, repos.Translation)
|
||||
authorService := author.NewService(repos.Author)
|
||||
bookService := book.NewService(repos.Book, authzService)
|
||||
bookmarkService := bookmark.NewService(repos.Bookmark)
|
||||
categoryService := category.NewService(repos.Category)
|
||||
collectionService := collection.NewService(repos.Collection)
|
||||
commentService := comment.NewService(repos.Comment)
|
||||
commentService := comment.NewService(repos.Comment, authzService)
|
||||
likeService := like.NewService(repos.Like)
|
||||
tagService := tag.NewService(repos.Tag)
|
||||
translationService := translation.NewService(repos.Translation)
|
||||
userService := user.NewService(repos.User)
|
||||
translationService := translation.NewService(repos.Translation, authzService)
|
||||
userService := user.NewService(repos.User, authzService)
|
||||
localizationService := localization.NewService(repos.Localization)
|
||||
authService := auth.NewService(repos.User, jwtManager)
|
||||
workService := work.NewService(repos.Work, searchClient)
|
||||
workService := work.NewService(repos.Work, searchClient, authzService)
|
||||
|
||||
return &Application{
|
||||
Author: authorService,
|
||||
Book: bookService,
|
||||
Bookmark: bookmarkService,
|
||||
Category: categoryService,
|
||||
Collection: collectionService,
|
||||
@ -63,6 +71,7 @@ func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, a
|
||||
User: userService,
|
||||
Localization: localizationService,
|
||||
Auth: authService,
|
||||
Authz: authzService,
|
||||
Work: workService,
|
||||
Analytics: analyticsService,
|
||||
}
|
||||
|
||||
188
internal/app/authz/authz.go
Normal file
188
internal/app/authz/authz.go
Normal 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
|
||||
}
|
||||
118
internal/app/book/commands.go
Normal file
118
internal/app/book/commands.go
Normal 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)
|
||||
}
|
||||
26
internal/app/book/queries.go
Normal file
26
internal/app/book/queries.go
Normal 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)
|
||||
}
|
||||
20
internal/app/book/service.go
Normal file
20
internal/app/book/service.go
Normal 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),
|
||||
}
|
||||
}
|
||||
@ -2,17 +2,27 @@ package comment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CommentCommands contains the command handlers for the comment aggregate.
|
||||
type CommentCommands struct {
|
||||
repo domain.CommentRepository
|
||||
repo domain.CommentRepository
|
||||
authzSvc *authz.Service
|
||||
}
|
||||
|
||||
// NewCommentCommands creates a new CommentCommands handler.
|
||||
func NewCommentCommands(repo domain.CommentRepository) *CommentCommands {
|
||||
return &CommentCommands{repo: repo}
|
||||
func NewCommentCommands(repo domain.CommentRepository, authzSvc *authz.Service) *CommentCommands {
|
||||
return &CommentCommands{
|
||||
repo: repo,
|
||||
authzSvc: authzSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCommentInput represents the input for creating a new comment.
|
||||
@ -46,12 +56,29 @@ type UpdateCommentInput struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// UpdateComment updates an existing comment.
|
||||
// UpdateComment updates an existing comment after an authorization check.
|
||||
func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) {
|
||||
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
comment, err := c.repo.GetByID(ctx, input.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, input.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment) // Using CanDeleteComment for editing as well
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !can {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
comment.Text = input.Text
|
||||
err = c.repo.Update(ctx, comment)
|
||||
if err != nil {
|
||||
@ -60,7 +87,28 @@ func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateComment
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// DeleteComment deletes a comment by ID.
|
||||
// DeleteComment deletes a comment by ID after an authorization check.
|
||||
func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error {
|
||||
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
comment, err := c.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !can {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
return c.repo.Delete(ctx, id)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
package comment
|
||||
|
||||
import "tercul/internal/domain"
|
||||
import (
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
)
|
||||
|
||||
// Service is the application service for the comment aggregate.
|
||||
type Service struct {
|
||||
@ -9,9 +12,9 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService creates a new comment Service.
|
||||
func NewService(repo domain.CommentRepository) *Service {
|
||||
func NewService(repo domain.CommentRepository, authzSvc *authz.Service) *Service {
|
||||
return &Service{
|
||||
Commands: NewCommentCommands(repo),
|
||||
Commands: NewCommentCommands(repo, authzSvc),
|
||||
Queries: NewCommentQueries(repo),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,17 +2,27 @@ package translation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TranslationCommands contains the command handlers for the translation aggregate.
|
||||
type TranslationCommands struct {
|
||||
repo domain.TranslationRepository
|
||||
repo domain.TranslationRepository
|
||||
authzSvc *authz.Service
|
||||
}
|
||||
|
||||
// NewTranslationCommands creates a new TranslationCommands handler.
|
||||
func NewTranslationCommands(repo domain.TranslationRepository) *TranslationCommands {
|
||||
return &TranslationCommands{repo: repo}
|
||||
func NewTranslationCommands(repo domain.TranslationRepository, authzSvc *authz.Service) *TranslationCommands {
|
||||
return &TranslationCommands{
|
||||
repo: repo,
|
||||
authzSvc: authzSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTranslationInput represents the input for creating a new translation.
|
||||
@ -60,10 +70,27 @@ type UpdateTranslationInput struct {
|
||||
|
||||
// UpdateTranslation updates an existing translation.
|
||||
func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input UpdateTranslationInput) (*domain.Translation, error) {
|
||||
translation, err := c.repo.GetByID(ctx, input.ID)
|
||||
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
can, err := c.authzSvc.CanEditTranslation(ctx, userID, input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !can {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
translation, err := c.repo.GetByID(ctx, input.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("%w: translation with id %d not found", domain.ErrNotFound, input.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
translation.Title = input.Title
|
||||
translation.Content = input.Content
|
||||
translation.Description = input.Description
|
||||
@ -78,5 +105,13 @@ func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input Updat
|
||||
|
||||
// DeleteTranslation deletes a translation by ID.
|
||||
func (c *TranslationCommands) DeleteTranslation(ctx context.Context, id uint) error {
|
||||
can, err := c.authzSvc.CanDeleteTranslation(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !can {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
return c.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package translation
|
||||
|
||||
import "tercul/internal/domain"
|
||||
import (
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
)
|
||||
|
||||
// Service is the application service for the translation aggregate.
|
||||
type Service struct {
|
||||
@ -9,9 +12,9 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService creates a new translation Service.
|
||||
func NewService(repo domain.TranslationRepository) *Service {
|
||||
func NewService(repo domain.TranslationRepository, authzSvc *authz.Service) *Service {
|
||||
return &Service{
|
||||
Commands: NewTranslationCommands(repo),
|
||||
Commands: NewTranslationCommands(repo, authzSvc),
|
||||
Queries: NewTranslationQueries(repo),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,17 +2,27 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserCommands contains the command handlers for the user aggregate.
|
||||
type UserCommands struct {
|
||||
repo domain.UserRepository
|
||||
repo domain.UserRepository
|
||||
authzSvc *authz.Service
|
||||
}
|
||||
|
||||
// NewUserCommands creates a new UserCommands handler.
|
||||
func NewUserCommands(repo domain.UserRepository) *UserCommands {
|
||||
return &UserCommands{repo: repo}
|
||||
func NewUserCommands(repo domain.UserRepository, authzSvc *authz.Service) *UserCommands {
|
||||
return &UserCommands{
|
||||
repo: repo,
|
||||
authzSvc: authzSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUserInput represents the input for creating a new user.
|
||||
@ -44,25 +54,92 @@ func (c *UserCommands) CreateUser(ctx context.Context, input CreateUserInput) (*
|
||||
|
||||
// UpdateUserInput represents the input for updating an existing user.
|
||||
type UpdateUserInput struct {
|
||||
ID uint
|
||||
Username string
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
Role domain.UserRole
|
||||
ID uint
|
||||
Username *string
|
||||
Email *string
|
||||
Password *string
|
||||
FirstName *string
|
||||
LastName *string
|
||||
DisplayName *string
|
||||
Bio *string
|
||||
AvatarURL *string
|
||||
Role *domain.UserRole
|
||||
Verified *bool
|
||||
Active *bool
|
||||
CountryID *uint
|
||||
CityID *uint
|
||||
AddressID *uint
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user.
|
||||
func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*domain.User, error) {
|
||||
user, err := c.repo.GetByID(ctx, input.ID)
|
||||
actorID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
can, err := c.authzSvc.CanUpdateUser(ctx, actorID, input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Username = input.Username
|
||||
user.Email = input.Email
|
||||
user.FirstName = input.FirstName
|
||||
user.LastName = input.LastName
|
||||
user.Role = input.Role
|
||||
if !can {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
user, err := c.repo.GetByID(ctx, input.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("%w: user with id %d not found", domain.ErrNotFound, input.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply partial updates
|
||||
if input.Username != nil {
|
||||
user.Username = *input.Username
|
||||
}
|
||||
if input.Email != nil {
|
||||
user.Email = *input.Email
|
||||
}
|
||||
if input.Password != nil {
|
||||
if err := user.SetPassword(*input.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.FirstName != nil {
|
||||
user.FirstName = *input.FirstName
|
||||
}
|
||||
if input.LastName != nil {
|
||||
user.LastName = *input.LastName
|
||||
}
|
||||
if input.DisplayName != nil {
|
||||
user.DisplayName = *input.DisplayName
|
||||
}
|
||||
if input.Bio != nil {
|
||||
user.Bio = *input.Bio
|
||||
}
|
||||
if input.AvatarURL != nil {
|
||||
user.AvatarURL = *input.AvatarURL
|
||||
}
|
||||
if input.Role != nil {
|
||||
user.Role = *input.Role
|
||||
}
|
||||
if input.Verified != nil {
|
||||
user.Verified = *input.Verified
|
||||
}
|
||||
if input.Active != nil {
|
||||
user.Active = *input.Active
|
||||
}
|
||||
if input.CountryID != nil {
|
||||
user.CountryID = input.CountryID
|
||||
}
|
||||
if input.CityID != nil {
|
||||
user.CityID = input.CityID
|
||||
}
|
||||
if input.AddressID != nil {
|
||||
user.AddressID = input.AddressID
|
||||
}
|
||||
|
||||
err = c.repo.Update(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -72,5 +149,18 @@ func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*
|
||||
|
||||
// DeleteUser deletes a user by ID.
|
||||
func (c *UserCommands) DeleteUser(ctx context.Context, id uint) error {
|
||||
actorID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
can, err := c.authzSvc.CanUpdateUser(ctx, actorID, id) // Re-using CanUpdateUser for deletion
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !can {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
return c.repo.Delete(ctx, id)
|
||||
}
|
||||
}
|
||||
102
internal/app/user/commands_test.go
Normal file
102
internal/app/user/commands_test.go
Normal 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
|
||||
}
|
||||
32
internal/app/user/main_test.go
Normal file
32
internal/app/user/main_test.go
Normal 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
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
package user
|
||||
|
||||
import "tercul/internal/domain"
|
||||
import (
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
)
|
||||
|
||||
// Service is the application service for the user aggregate.
|
||||
type Service struct {
|
||||
@ -9,9 +12,9 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService creates a new user Service.
|
||||
func NewService(repo domain.UserRepository) *Service {
|
||||
func NewService(repo domain.UserRepository, authzSvc *authz.Service) *Service {
|
||||
return &Service{
|
||||
Commands: NewUserCommands(repo),
|
||||
Commands: NewUserCommands(repo, authzSvc),
|
||||
Queries: NewUserQueries(repo),
|
||||
}
|
||||
}
|
||||
|
||||
71
internal/app/user/work_repo_mock_test.go
Normal file
71
internal/app/user/work_repo_mock_test.go
Normal 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
|
||||
}
|
||||
@ -3,21 +3,29 @@ package work
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
"tercul/internal/domain/search"
|
||||
"tercul/internal/domain/work"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WorkCommands contains the command handlers for the work aggregate.
|
||||
type WorkCommands struct {
|
||||
repo work.WorkRepository
|
||||
searchClient search.SearchClient
|
||||
authzSvc *authz.Service
|
||||
}
|
||||
|
||||
// NewWorkCommands creates a new WorkCommands handler.
|
||||
func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient) *WorkCommands {
|
||||
func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *WorkCommands {
|
||||
return &WorkCommands{
|
||||
repo: repo,
|
||||
searchClient: searchClient,
|
||||
authzSvc: authzSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,21 +52,44 @@ func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.W
|
||||
return work, nil
|
||||
}
|
||||
|
||||
// UpdateWork updates an existing work.
|
||||
// UpdateWork updates an existing work after performing an authorization check.
|
||||
func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
|
||||
if work == nil {
|
||||
return errors.New("work cannot be nil")
|
||||
return fmt.Errorf("%w: work cannot be nil", domain.ErrValidation)
|
||||
}
|
||||
if work.ID == 0 {
|
||||
return errors.New("work ID cannot be zero")
|
||||
return fmt.Errorf("%w: work ID cannot be zero", domain.ErrValidation)
|
||||
}
|
||||
|
||||
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
existingWork, err := c.repo.GetByID(ctx, work.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, work.ID)
|
||||
}
|
||||
return fmt.Errorf("failed to get work for authorization: %w", err)
|
||||
}
|
||||
|
||||
can, err := c.authzSvc.CanEditWork(ctx, userID, existingWork)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !can {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
if work.Title == "" {
|
||||
return errors.New("work title cannot be empty")
|
||||
return fmt.Errorf("%w: work title cannot be empty", domain.ErrValidation)
|
||||
}
|
||||
if work.Language == "" {
|
||||
return errors.New("work language cannot be empty")
|
||||
return fmt.Errorf("%w: work language cannot be empty", domain.ErrValidation)
|
||||
}
|
||||
err := c.repo.Update(ctx, work)
|
||||
|
||||
err = c.repo.Update(ctx, work)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -66,11 +97,36 @@ func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
|
||||
return c.searchClient.IndexWork(ctx, work, "")
|
||||
}
|
||||
|
||||
// DeleteWork deletes a work by ID.
|
||||
// DeleteWork deletes a work by ID after performing an authorization check.
|
||||
func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
|
||||
if id == 0 {
|
||||
return errors.New("invalid work ID")
|
||||
return fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
|
||||
}
|
||||
|
||||
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
existingWork, err := c.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, id)
|
||||
}
|
||||
return fmt.Errorf("failed to get work for authorization: %w", err)
|
||||
}
|
||||
|
||||
can, err := c.authzSvc.CanDeleteWork(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !can {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
_ = userID // to avoid unused variable error
|
||||
_ = existingWork // to avoid unused variable error
|
||||
|
||||
return c.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
|
||||
@ -5,8 +5,10 @@ import (
|
||||
"errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain"
|
||||
workdomain "tercul/internal/domain/work"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -14,13 +16,15 @@ type WorkCommandsSuite struct {
|
||||
suite.Suite
|
||||
repo *mockWorkRepository
|
||||
searchClient *mockSearchClient
|
||||
authzSvc *authz.Service
|
||||
commands *WorkCommands
|
||||
}
|
||||
|
||||
func (s *WorkCommandsSuite) SetupTest() {
|
||||
s.repo = &mockWorkRepository{}
|
||||
s.searchClient = &mockSearchClient{}
|
||||
s.commands = NewWorkCommands(s.repo, s.searchClient)
|
||||
s.authzSvc = authz.NewService(s.repo, nil)
|
||||
s.commands = NewWorkCommands(s.repo, s.searchClient, s.authzSvc)
|
||||
}
|
||||
|
||||
func TestWorkCommandsSuite(t *testing.T) {
|
||||
@ -60,9 +64,18 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() {
|
||||
}
|
||||
|
||||
func (s *WorkCommandsSuite) TestUpdateWork_Success() {
|
||||
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
|
||||
work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
|
||||
work.ID = 1
|
||||
err := s.commands.UpdateWork(context.Background(), work)
|
||||
|
||||
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) {
|
||||
return work, nil
|
||||
}
|
||||
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err := s.commands.UpdateWork(ctx, work)
|
||||
assert.NoError(s.T(), err)
|
||||
}
|
||||
|
||||
@ -102,7 +115,18 @@ func (s *WorkCommandsSuite) TestUpdateWork_RepoError() {
|
||||
}
|
||||
|
||||
func (s *WorkCommandsSuite) TestDeleteWork_Success() {
|
||||
err := s.commands.DeleteWork(context.Background(), 1)
|
||||
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
|
||||
work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
|
||||
work.ID = 1
|
||||
|
||||
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) {
|
||||
return work, nil
|
||||
}
|
||||
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err := s.commands.DeleteWork(ctx, 1)
|
||||
assert.NoError(s.T(), err)
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,14 @@ type mockWorkRepository struct {
|
||||
findByAuthorFunc func(ctx context.Context, authorID uint) ([]work.Work, error)
|
||||
findByCategoryFunc func(ctx context.Context, categoryID uint) ([]work.Work, error)
|
||||
findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error)
|
||||
isAuthorFunc func(ctx context.Context, workID uint, authorID uint) (bool, error)
|
||||
}
|
||||
|
||||
func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||
if m.isAuthorFunc != nil {
|
||||
return m.isAuthorFunc(ctx, workID, authorID)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkRepository) Create(ctx context.Context, work *work.Work) error {
|
||||
@ -42,7 +50,7 @@ func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work,
|
||||
if m.getByIDFunc != nil {
|
||||
return m.getByIDFunc(ctx, id)
|
||||
}
|
||||
return nil, nil
|
||||
return &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}, nil
|
||||
}
|
||||
func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||
if m.listFunc != nil {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package work
|
||||
|
||||
import (
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/domain/search"
|
||||
"tercul/internal/domain/work"
|
||||
)
|
||||
@ -12,9 +13,9 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService creates a new work Service.
|
||||
func NewService(repo work.WorkRepository, searchClient search.SearchClient) *Service {
|
||||
func NewService(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *Service {
|
||||
return &Service{
|
||||
Commands: NewWorkCommands(repo, searchClient),
|
||||
Commands: NewWorkCommands(repo, searchClient, authzSvc),
|
||||
Queries: NewWorkQueries(repo),
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +120,21 @@ func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*wor
|
||||
return r.FindWithPreload(ctx, []string{"Translations"}, id)
|
||||
}
|
||||
|
||||
// IsAuthor checks if a user is an author of a work.
|
||||
// Note: This assumes a direct relationship between user ID and author ID,
|
||||
// which may need to be revised based on the actual domain model.
|
||||
func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("work_authors").
|
||||
Where("work_id = ? AND author_id = ?", workID, authorID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ListWithTranslations lists works with their translations
|
||||
func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||
if page < 1 {
|
||||
|
||||
20
internal/domain/errors.go
Normal file
20
internal/domain/errors.go
Normal 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")
|
||||
)
|
||||
@ -14,4 +14,5 @@ type WorkRepository interface {
|
||||
FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
||||
GetWithTranslations(ctx context.Context, id uint) (*Work, error)
|
||||
ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
||||
IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error)
|
||||
}
|
||||
54
internal/observability/logger.go
Normal file
54
internal/observability/logger.go
Normal 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}
|
||||
}
|
||||
55
internal/observability/metrics.go
Normal file
55
internal/observability/metrics.go
Normal 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{})
|
||||
}
|
||||
56
internal/observability/middleware.go
Normal file
56
internal/observability/middleware.go
Normal 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))
|
||||
})
|
||||
}
|
||||
41
internal/observability/tracing.go
Normal file
41
internal/observability/tracing.go
Normal 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)
|
||||
}
|
||||
@ -88,27 +88,29 @@ func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler {
|
||||
func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// For GraphQL, we want to authenticate but not block requests
|
||||
// This allows for both authenticated and anonymous queries
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader)
|
||||
if err == nil {
|
||||
claims, err := jwtManager.ValidateToken(tokenString)
|
||||
if err == nil {
|
||||
// Add claims to context for authenticated requests
|
||||
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
// If token is invalid, log warning but continue
|
||||
log.LogWarn("GraphQL authentication failed - continuing with anonymous access",
|
||||
log.F("path", r.URL.Path))
|
||||
if authHeader == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Continue without authentication
|
||||
next.ServeHTTP(w, r)
|
||||
tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader)
|
||||
if err != nil {
|
||||
log.LogWarn("GraphQL authentication failed - could not extract token", log.F("error", err))
|
||||
next.ServeHTTP(w, r) // Proceed without auth
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtManager.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
log.LogWarn("GraphQL authentication failed - invalid token", log.F("error", err))
|
||||
next.ServeHTTP(w, r) // Proceed without auth
|
||||
return
|
||||
}
|
||||
|
||||
// Add claims to context for authenticated requests
|
||||
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -187,3 +189,12 @@ func ContextWithUserID(ctx context.Context, userID uint) context.Context {
|
||||
claims := &Claims{UserID: userID}
|
||||
return context.WithValue(ctx, ClaimsContextKey, claims)
|
||||
}
|
||||
|
||||
// ContextWithAdminUser adds an admin user to the context for testing purposes.
|
||||
func ContextWithAdminUser(ctx context.Context, userID uint) context.Context {
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Role: "admin",
|
||||
}
|
||||
return context.WithValue(ctx, ClaimsContextKey, claims)
|
||||
}
|
||||
|
||||
@ -1,32 +1,145 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"tercul/internal/observability"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// LogLevel represents the severity level of a log message
|
||||
// LogLevel represents the severity level of a log message.
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
// DebugLevel for detailed troubleshooting
|
||||
// DebugLevel for detailed troubleshooting.
|
||||
DebugLevel LogLevel = iota
|
||||
// InfoLevel for general operational information
|
||||
// InfoLevel for general operational information.
|
||||
InfoLevel
|
||||
// WarnLevel for potentially harmful situations
|
||||
// WarnLevel for potentially harmful situations.
|
||||
WarnLevel
|
||||
// ErrorLevel for error events that might still allow the application to continue
|
||||
// ErrorLevel for error events that might still allow the application to continue.
|
||||
ErrorLevel
|
||||
// FatalLevel for severe error events that will lead the application to abort
|
||||
// FatalLevel for severe error events that will lead the application to abort.
|
||||
FatalLevel
|
||||
)
|
||||
|
||||
// String returns the string representation of the log level
|
||||
// Field represents a key-value pair for structured logging.
|
||||
type Field struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// F creates a new Field.
|
||||
func F(key string, value interface{}) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
// Logger provides structured logging capabilities.
|
||||
type Logger struct {
|
||||
*observability.Logger
|
||||
}
|
||||
|
||||
var defaultLogger = &Logger{observability.NewLogger("tercul", "development")}
|
||||
|
||||
// Init re-initializes the default logger. This is useful for applications
|
||||
// that need to configure the logger with dynamic values.
|
||||
func Init(serviceName, environment string) {
|
||||
defaultLogger = &Logger{observability.NewLogger(serviceName, environment)}
|
||||
}
|
||||
|
||||
// SetDefaultLevel sets the log level for the default logger.
|
||||
func SetDefaultLevel(level LogLevel) {
|
||||
var zlevel zerolog.Level
|
||||
switch level {
|
||||
case DebugLevel:
|
||||
zlevel = zerolog.DebugLevel
|
||||
case InfoLevel:
|
||||
zlevel = zerolog.InfoLevel
|
||||
case WarnLevel:
|
||||
zlevel = zerolog.WarnLevel
|
||||
case ErrorLevel:
|
||||
zlevel = zerolog.ErrorLevel
|
||||
case FatalLevel:
|
||||
zlevel = zerolog.FatalLevel
|
||||
default:
|
||||
zlevel = zerolog.InfoLevel
|
||||
}
|
||||
zerolog.SetGlobalLevel(zlevel)
|
||||
}
|
||||
|
||||
func log(level LogLevel, msg string, fields ...Field) {
|
||||
var event *zerolog.Event
|
||||
// Access the embedded observability.Logger to get to zerolog's methods.
|
||||
zlog := defaultLogger.Logger
|
||||
switch level {
|
||||
case DebugLevel:
|
||||
event = zlog.Debug()
|
||||
case InfoLevel:
|
||||
event = zlog.Info()
|
||||
case WarnLevel:
|
||||
event = zlog.Warn()
|
||||
case ErrorLevel:
|
||||
event = zlog.Error()
|
||||
case FatalLevel:
|
||||
event = zlog.Fatal()
|
||||
default:
|
||||
event = zlog.Info()
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
event.Interface(f.Key, f.Value)
|
||||
}
|
||||
event.Msg(msg)
|
||||
}
|
||||
|
||||
// LogDebug logs a message at debug level using the default logger.
|
||||
func LogDebug(msg string, fields ...Field) {
|
||||
log(DebugLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// LogInfo logs a message at info level using the default logger.
|
||||
func LogInfo(msg string, fields ...Field) {
|
||||
log(InfoLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// LogWarn logs a message at warn level using the default logger.
|
||||
func LogWarn(msg string, fields ...Field) {
|
||||
log(WarnLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// LogError logs a message at error level using the default logger.
|
||||
func LogError(msg string, fields ...Field) {
|
||||
log(ErrorLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// LogFatal logs a message at fatal level using the default logger and then calls os.Exit(1).
|
||||
func LogFatal(msg string, fields ...Field) {
|
||||
log(FatalLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// WithFields returns a new logger with the given fields added using the default logger.
|
||||
func WithFields(fields ...Field) *Logger {
|
||||
sublogger := defaultLogger.With().Logger()
|
||||
for _, f := range fields {
|
||||
sublogger = sublogger.With().Interface(f.Key, f.Value).Logger()
|
||||
}
|
||||
return &Logger{&observability.Logger{&sublogger}}
|
||||
}
|
||||
|
||||
// WithContext returns a new logger with the given context added using the default logger.
|
||||
func WithContext(ctx context.Context) *Logger {
|
||||
return &Logger{defaultLogger.Ctx(ctx)}
|
||||
}
|
||||
|
||||
// The following functions are kept for compatibility but are now simplified or deprecated.
|
||||
|
||||
// SetDefaultLogger is deprecated. Use Init.
|
||||
func SetDefaultLogger(logger *Logger) {
|
||||
// Deprecated: Logger is now initialized via Init.
|
||||
}
|
||||
|
||||
// String returns the string representation of the log level.
|
||||
func (l LogLevel) String() string {
|
||||
switch l {
|
||||
case DebugLevel:
|
||||
@ -44,192 +157,83 @@ func (l LogLevel) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Field represents a key-value pair for structured logging
|
||||
type Field struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// F creates a new Field
|
||||
func F(key string, value interface{}) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
// Logger provides structured logging capabilities
|
||||
type Logger struct {
|
||||
level LogLevel
|
||||
writer io.Writer
|
||||
fields []Field
|
||||
context map[string]interface{}
|
||||
}
|
||||
|
||||
// New creates a new Logger with the specified log level and writer
|
||||
func New(level LogLevel, writer io.Writer) *Logger {
|
||||
if writer == nil {
|
||||
writer = os.Stdout
|
||||
}
|
||||
return &Logger{
|
||||
level: level,
|
||||
writer: writer,
|
||||
fields: []Field{},
|
||||
context: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logs a message at debug level
|
||||
// Debug logs a message at debug level.
|
||||
func (l *Logger) Debug(msg string, fields ...Field) {
|
||||
if l.level <= DebugLevel {
|
||||
l.log(DebugLevel, msg, fields...)
|
||||
}
|
||||
l.log(DebugLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// Info logs a message at info level
|
||||
// Info logs a message at info level.
|
||||
func (l *Logger) Info(msg string, fields ...Field) {
|
||||
if l.level <= InfoLevel {
|
||||
l.log(InfoLevel, msg, fields...)
|
||||
}
|
||||
l.log(InfoLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// Warn logs a message at warn level
|
||||
// Warn logs a message at warn level.
|
||||
func (l *Logger) Warn(msg string, fields ...Field) {
|
||||
if l.level <= WarnLevel {
|
||||
l.log(WarnLevel, msg, fields...)
|
||||
}
|
||||
l.log(WarnLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// Error logs a message at error level
|
||||
// Error logs a message at error level.
|
||||
func (l *Logger) Error(msg string, fields ...Field) {
|
||||
if l.level <= ErrorLevel {
|
||||
l.log(ErrorLevel, msg, fields...)
|
||||
}
|
||||
l.log(ErrorLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// Fatal logs a message at fatal level and then calls os.Exit(1)
|
||||
// Fatal logs a message at fatal level and then calls os.Exit(1).
|
||||
func (l *Logger) Fatal(msg string, fields ...Field) {
|
||||
if l.level <= FatalLevel {
|
||||
l.log(FatalLevel, msg, fields...)
|
||||
os.Exit(1)
|
||||
}
|
||||
l.log(FatalLevel, msg, fields...)
|
||||
}
|
||||
|
||||
// WithFields returns a new logger with the given fields added
|
||||
func (l *Logger) WithFields(fields ...Field) *Logger {
|
||||
newLogger := &Logger{
|
||||
level: l.level,
|
||||
writer: l.writer,
|
||||
fields: append(l.fields, fields...),
|
||||
context: l.context,
|
||||
}
|
||||
return newLogger
|
||||
}
|
||||
|
||||
// WithContext returns a new logger with the given context added
|
||||
func (l *Logger) WithContext(ctx map[string]interface{}) *Logger {
|
||||
newContext := make(map[string]interface{})
|
||||
for k, v := range l.context {
|
||||
newContext[k] = v
|
||||
}
|
||||
for k, v := range ctx {
|
||||
newContext[k] = v
|
||||
}
|
||||
|
||||
newLogger := &Logger{
|
||||
level: l.level,
|
||||
writer: l.writer,
|
||||
fields: l.fields,
|
||||
context: newContext,
|
||||
}
|
||||
return newLogger
|
||||
}
|
||||
|
||||
// SetLevel sets the log level
|
||||
func (l *Logger) SetLevel(level LogLevel) {
|
||||
l.level = level
|
||||
}
|
||||
|
||||
// log formats and writes a log message
|
||||
func (l *Logger) log(level LogLevel, msg string, fields ...Field) {
|
||||
timestamp := time.Now().Format(time.RFC3339)
|
||||
|
||||
// Get caller information
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
caller := "unknown"
|
||||
if ok {
|
||||
parts := strings.Split(file, "/")
|
||||
if len(parts) > 2 {
|
||||
caller = fmt.Sprintf("%s:%d", parts[len(parts)-1], line)
|
||||
} else {
|
||||
caller = fmt.Sprintf("%s:%d", file, line)
|
||||
}
|
||||
var event *zerolog.Event
|
||||
switch level {
|
||||
case DebugLevel:
|
||||
event = l.Logger.Debug()
|
||||
case InfoLevel:
|
||||
event = l.Logger.Info()
|
||||
case WarnLevel:
|
||||
event = l.Logger.Warn()
|
||||
case ErrorLevel:
|
||||
event = l.Logger.Error()
|
||||
case FatalLevel:
|
||||
event = l.Logger.Fatal()
|
||||
default:
|
||||
event = l.Logger.Info()
|
||||
}
|
||||
|
||||
// Format fields
|
||||
allFields := append(l.fields, fields...)
|
||||
fieldStr := ""
|
||||
for _, field := range allFields {
|
||||
fieldStr += fmt.Sprintf(" %s=%v", field.Key, field.Value)
|
||||
for _, f := range fields {
|
||||
event.Interface(f.Key, f.Value)
|
||||
}
|
||||
event.Msg(msg)
|
||||
}
|
||||
|
||||
// Format context
|
||||
contextStr := ""
|
||||
for k, v := range l.context {
|
||||
contextStr += fmt.Sprintf(" %s=%v", k, v)
|
||||
}
|
||||
|
||||
// Format log message
|
||||
logMsg := fmt.Sprintf("%s [%s] %s %s%s%s\n", timestamp, level.String(), caller, msg, fieldStr, contextStr)
|
||||
|
||||
// Write log message
|
||||
_, err := l.writer.Write([]byte(logMsg))
|
||||
if err != nil {
|
||||
log.Printf("Error writing log message: %v", err)
|
||||
// WithFields returns a new logger with the given fields added.
|
||||
func (l *Logger) WithFields(fields ...Field) *Logger {
|
||||
sublogger := l.With().Logger()
|
||||
for _, f := range fields {
|
||||
sublogger = sublogger.With().Interface(f.Key, f.Value).Logger()
|
||||
}
|
||||
return &Logger{&observability.Logger{&sublogger}}
|
||||
}
|
||||
|
||||
// Global logger instance
|
||||
var defaultLogger = New(InfoLevel, os.Stdout)
|
||||
|
||||
// SetDefaultLogger sets the global logger instance
|
||||
func SetDefaultLogger(logger *Logger) {
|
||||
defaultLogger = logger
|
||||
func (l *Logger) WithContext(ctx map[string]interface{}) *Logger {
|
||||
// To maintain compatibility with the old API, we will convert the map to a context.
|
||||
// This is not ideal and should be refactored in the future.
|
||||
zlog := l.Logger.With().Logger()
|
||||
for k, v := range ctx {
|
||||
zlog = zlog.With().Interface(k, v).Logger()
|
||||
}
|
||||
return &Logger{&observability.Logger{&zlog}}
|
||||
}
|
||||
|
||||
// SetDefaultLevel sets the log level for the default logger
|
||||
func SetDefaultLevel(level LogLevel) {
|
||||
defaultLogger.SetLevel(level)
|
||||
func (l *Logger) SetLevel(level LogLevel) {
|
||||
// This now controls the global log level.
|
||||
SetDefaultLevel(level)
|
||||
}
|
||||
|
||||
// LogDebug logs a message at debug level using the default logger
|
||||
func LogDebug(msg string, fields ...Field) {
|
||||
defaultLogger.Debug(msg, fields...)
|
||||
// Fmt versions for simple string formatting
|
||||
func LogInfof(format string, v ...interface{}) {
|
||||
log(InfoLevel, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
// LogInfo logs a message at info level using the default logger
|
||||
func LogInfo(msg string, fields ...Field) {
|
||||
defaultLogger.Info(msg, fields...)
|
||||
}
|
||||
|
||||
// LogWarn logs a message at warn level using the default logger
|
||||
func LogWarn(msg string, fields ...Field) {
|
||||
defaultLogger.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// LogError logs a message at error level using the default logger
|
||||
func LogError(msg string, fields ...Field) {
|
||||
defaultLogger.Error(msg, fields...)
|
||||
}
|
||||
|
||||
// LogFatal logs a message at fatal level using the default logger and then calls os.Exit(1)
|
||||
func LogFatal(msg string, fields ...Field) {
|
||||
defaultLogger.Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
// WithFields returns a new logger with the given fields added using the default logger
|
||||
func WithFields(fields ...Field) *Logger {
|
||||
return defaultLogger.WithFields(fields...)
|
||||
}
|
||||
|
||||
// WithContext returns a new logger with the given context added using the default logger
|
||||
func WithContext(ctx map[string]interface{}) *Logger {
|
||||
return defaultLogger.WithContext(ctx)
|
||||
}
|
||||
func LogErrorf(format string, v ...interface{}) {
|
||||
log(ErrorLevel, fmt.Sprintf(format, v...))
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -2,45 +2,8 @@ package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
graph "tercul/internal/adapters/graphql"
|
||||
"tercul/internal/app"
|
||||
"tercul/internal/app/localization"
|
||||
"tercul/internal/app/work"
|
||||
"tercul/internal/domain"
|
||||
domain_localization "tercul/internal/domain/localization"
|
||||
domain_work "tercul/internal/domain/work"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// SimpleTestSuite provides a minimal test environment with just the essentials
|
||||
type SimpleTestSuite struct {
|
||||
suite.Suite
|
||||
WorkRepo *MockWorkRepository
|
||||
WorkService *work.Service
|
||||
MockSearchClient *MockSearchClient
|
||||
}
|
||||
|
||||
// MockSearchClient is a mock implementation of the search.SearchClient interface.
|
||||
type MockSearchClient struct{}
|
||||
|
||||
// IndexWork is the mock implementation of the IndexWork method.
|
||||
func (m *MockSearchClient) IndexWork(ctx context.Context, work *domain_work.Work, pipeline string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupSuite sets up the test suite
|
||||
func (s *SimpleTestSuite) SetupSuite() {
|
||||
s.WorkRepo = NewMockWorkRepository()
|
||||
s.MockSearchClient = &MockSearchClient{}
|
||||
s.WorkService = work.NewService(s.WorkRepo, s.MockSearchClient)
|
||||
}
|
||||
|
||||
// SetupTest resets test data for each test
|
||||
func (s *SimpleTestSuite) SetupTest() {
|
||||
s.WorkRepo = NewMockWorkRepository()
|
||||
}
|
||||
|
||||
// MockLocalizationRepository is a mock implementation of the localization repository.
|
||||
type MockLocalizationRepository struct{}
|
||||
|
||||
@ -59,34 +22,4 @@ func (m *MockLocalizationRepository) GetTranslations(ctx context.Context, keys [
|
||||
// GetAuthorBiography is a mock implementation of the GetAuthorBiography method.
|
||||
func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
|
||||
return "This is a mock biography.", nil
|
||||
}
|
||||
|
||||
// GetResolver returns a minimal GraphQL resolver for testing
|
||||
func (s *SimpleTestSuite) GetResolver() *graph.Resolver {
|
||||
var mockLocalizationRepo domain_localization.LocalizationRepository = &MockLocalizationRepository{}
|
||||
localizationService := localization.NewService(mockLocalizationRepo)
|
||||
|
||||
return &graph.Resolver{
|
||||
App: &app.Application{
|
||||
Work: s.WorkService,
|
||||
Localization: localizationService,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTestWork creates a test work with optional content
|
||||
func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) *domain_work.Work {
|
||||
work := &domain_work.Work{
|
||||
Title: title,
|
||||
TranslatableModel: domain.TranslatableModel{Language: language},
|
||||
}
|
||||
|
||||
// Add work to the mock repository
|
||||
createdWork, err := s.WorkService.Commands.CreateWork(context.Background(), work)
|
||||
s.Require().NoError(err)
|
||||
|
||||
// If content is provided, we'll need to handle it differently
|
||||
// since the mock repository doesn't support translations yet
|
||||
// For now, just return the work
|
||||
return createdWork
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user