diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..31ada2f --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.26-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \ + go install github.com/swaggo/swag/cmd/swag@latest && \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f725474 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "go-codebase", + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/docker-from-docker:2": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "golang.go", + "ms-azuretools.vscode-docker" + ] + } + }, + "forwardPorts": [5432], + "portsAttributes": { + "5432": { + "label": "PostgreSQL" + } + }, + "remoteEnv": { + "DB_HOST": "localhost", + "DB_PORT": "5432", + "DB_USER": "postgres", + "DB_PASSWORD": "postgres", + "DB_NAME": "codebase_testing", + "DB_SSLMODE": "disable" + }, + "postCreateCommand": "go mod download" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 472c5b4..447945e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: go_codebase_test + POSTGRES_DB: codebase_testing ports: - 5432:5432 options: >- @@ -109,11 +109,11 @@ jobs: DB_PORT: 5432 DB_USER: postgres DB_PASSWORD: postgres - DB_NAME: go_codebase_test + DB_NAME: codebase_testing DB_SSLMODE: disable REDIS_ADDR: localhost:6379 JWT_SECRET: test-secret-key-not-for-production - run: go test -race -count=1 ./... + run: go test -race -count=1 -v ./... build: name: Build diff --git a/Makefile b/Makefile index 801ad81..42a7445 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run build test lint fmt migrate-up migrate-down sqlc swagger docker-up docker-down docker-dev docker-dev-down clean rename install-tools precommit install-hooks +.PHONY: run build test test-unit test-integration lint fmt migrate-up migrate-down sqlc swagger docker-up docker-down docker-dev docker-dev-down clean rename install-tools precommit install-hooks APP_NAME := go-codebase BUILD_DIR := bin @@ -37,6 +37,12 @@ build: test: go test -v -count=1 ./... +test-unit: + go test -v -count=1 $(shell go list ./... | grep -v /tests/) + +test-integration: + go test -v -count=1 $(shell go list ./... | grep /tests/) + test-coverage: go test -v -count=1 -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..3d70d70 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,25 @@ +version: "3.8" + +# Test infrastructure — standalone PostgreSQL for running integration tests. +# Start before running tests: +# docker compose -f docker-compose.test.yml up -d +# Then run: +# go test -count=1 ./... +# +# Override DB_NAME via environment: +# DB_NAME=my_test_db docker compose -f docker-compose.test.yml up -d + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ${DB_NAME:-codebase_testing} + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 diff --git a/go.mod b/go.mod index c2485a1..95aa0d5 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/knadh/koanf/v2 v2.1.1 github.com/lib/pq v1.10.9 github.com/nats-io/nats.go v1.37.0 + github.com/pressly/goose/v3 v3.27.2 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.7.0 github.com/rs/cors v1.11.0 @@ -21,10 +22,10 @@ require ( github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 - go.opentelemetry.io/otel v1.31.0 + go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 - go.opentelemetry.io/otel/sdk v1.31.0 - go.opentelemetry.io/otel/trace v1.31.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/fx v1.23.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.53.0 @@ -41,7 +42,7 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonreference v1.0.0 // indirect @@ -57,9 +58,10 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -68,15 +70,17 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/swaggo/files v1.0.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/dig v1.18.0 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.37.0 // indirect @@ -85,9 +89,9 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.39.0 // indirect golang.org/x/tools v0.47.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect - google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6306e6a..d498f60 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -27,8 +29,8 @@ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcP github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= @@ -72,14 +74,16 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -100,6 +104,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -112,28 +120,36 @@ github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs= github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= @@ -143,18 +159,22 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4 github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= @@ -163,8 +183,8 @@ go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= @@ -213,16 +233,26 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/internal/authentication/domain/entity/refresh_token_test.go b/internal/authentication/domain/entity/refresh_token_test.go new file mode 100644 index 0000000..eec36e4 --- /dev/null +++ b/internal/authentication/domain/entity/refresh_token_test.go @@ -0,0 +1,52 @@ +package entity + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestNewRefreshToken(t *testing.T) { + userID := uuid.New() + expiresAt := time.Now().Add(time.Hour) + + token := NewRefreshToken(userID, "refresh-token-value", expiresAt) + + assert.NotEqual(t, uuid.Nil, token.ID) + assert.Equal(t, userID, token.UserID) + assert.Equal(t, "refresh-token-value", token.Token) + assert.Equal(t, expiresAt, token.ExpiresAt) + assert.Nil(t, token.RevokedAt) +} + +func TestRefreshTokenIsExpired(t *testing.T) { + t.Run("not expired", func(t *testing.T) { + token := NewRefreshToken(uuid.New(), "token", time.Now().Add(time.Hour)) + assert.False(t, token.IsExpired()) + }) + + t.Run("expired", func(t *testing.T) { + token := NewRefreshToken(uuid.New(), "token", time.Now().Add(-time.Hour)) + assert.True(t, token.IsExpired()) + }) +} + +func TestRefreshTokenIsRevoked(t *testing.T) { + token := NewRefreshToken(uuid.New(), "token", time.Now().Add(time.Hour)) + + assert.False(t, token.IsRevoked()) + + token.Revoke() + assert.True(t, token.IsRevoked()) +} + +func TestRefreshTokenRevoke(t *testing.T) { + token := NewRefreshToken(uuid.New(), "token", time.Now().Add(time.Hour)) + + token.Revoke() + + assert.NotNil(t, token.RevokedAt) + assert.True(t, token.UpdatedAt.Equal(*token.RevokedAt) || token.UpdatedAt.After(*token.RevokedAt)) +} diff --git a/internal/authentication/domain/entity/user_test.go b/internal/authentication/domain/entity/user_test.go index 5ea2d52..68214c6 100644 --- a/internal/authentication/domain/entity/user_test.go +++ b/internal/authentication/domain/entity/user_test.go @@ -3,49 +3,88 @@ package entity import ( "testing" "time" + + "github.com/stretchr/testify/assert" ) +func TestNewUser(t *testing.T) { + user := NewUser("test@example.com", "hashed_password", "Test User") + + assert.NotEqual(t, "", user.ID.String()) + assert.Equal(t, "test@example.com", user.Email) + assert.Equal(t, "hashed_password", user.Password) + assert.Equal(t, "Test User", user.Name) + assert.True(t, user.IsActive) + assert.Equal(t, 0, user.FailedLoginAttempts) + assert.Nil(t, user.LockedUntil) + assert.False(t, user.EmailVerified) +} + +func TestUserIsLocked(t *testing.T) { + t.Run("not locked", func(t *testing.T) { + user := NewUser("test@example.com", "pwd", "User") + assert.False(t, user.IsLocked()) + }) + + t.Run("locked until future", func(t *testing.T) { + user := NewUser("test@example.com", "pwd", "User") + user.Lock(time.Hour) + assert.True(t, user.IsLocked()) + }) + + t.Run("lock expired", func(t *testing.T) { + user := NewUser("test@example.com", "pwd", "User") + user.Lock(-time.Hour) + assert.False(t, user.IsLocked()) + }) +} + +func TestUserLock(t *testing.T) { + user := NewUser("test@example.com", "pwd", "User") + + user.Lock(30 * time.Minute) + + assert.Equal(t, 1, user.FailedLoginAttempts) + assert.NotNil(t, user.LockedUntil) + assert.True(t, time.Now().Before(*user.LockedUntil)) + + user.Lock(30 * time.Minute) + assert.Equal(t, 2, user.FailedLoginAttempts) +} + +func TestUserUnlock(t *testing.T) { + user := NewUser("test@example.com", "pwd", "User") + user.Lock(time.Hour) + assert.True(t, user.IsLocked()) + + user.Unlock() + + assert.Equal(t, 0, user.FailedLoginAttempts) + assert.Nil(t, user.LockedUntil) + assert.False(t, user.IsLocked()) +} + func TestUserEmailVerificationFields(t *testing.T) { user := NewUser("test@example.com", "hashed_password", "Test User") - // New fields should default to zero values - if user.EmailVerified { - t.Error("new user should not be email verified") - } - if user.EmailVerifyToken != nil { - t.Error("new user should not have verify token") - } - if user.EmailVerifyExpires != nil { - t.Error("new user should not have verify expires") - } - if user.PasswordResetToken != nil { - t.Error("new user should not have reset token") - } - if user.PasswordResetExpires != nil { - t.Error("new user should not have reset expires") - } - - // Test setting verification fields + assert.False(t, user.EmailVerified) + assert.Nil(t, user.EmailVerifyToken) + assert.Nil(t, user.EmailVerifyExpires) + assert.Nil(t, user.PasswordResetToken) + assert.Nil(t, user.PasswordResetExpires) + token := "verify-token-123" expires := time.Now().Add(24 * time.Hour) user.EmailVerifyToken = &token user.EmailVerifyExpires = &expires user.EmailVerified = true - if user.EmailVerifyToken == nil || *user.EmailVerifyToken != "verify-token-123" { - t.Error("verify token not set correctly") - } - if user.EmailVerifyExpires == nil || !user.EmailVerifyExpires.Equal(expires) { - t.Error("verify expires not set correctly") - } - if !user.EmailVerified { - t.Error("email_verified not set correctly") - } - - // Test clearing verification fields + assert.Equal(t, "verify-token-123", *user.EmailVerifyToken) + assert.Equal(t, expires, *user.EmailVerifyExpires) + assert.True(t, user.EmailVerified) + user.EmailVerifyToken = nil user.EmailVerifyExpires = nil - if user.EmailVerifyToken != nil { - t.Error("verify token should be nil after clearing") - } + assert.Nil(t, user.EmailVerifyToken) + assert.Nil(t, user.EmailVerifyExpires) } diff --git a/internal/authentication/infrastructure/persistence/user_repository.go b/internal/authentication/infrastructure/persistence/user_repository.go index b485e54..090e061 100644 --- a/internal/authentication/infrastructure/persistence/user_repository.go +++ b/internal/authentication/infrastructure/persistence/user_repository.go @@ -76,9 +76,9 @@ func (r *userRepository) Create(ctx context.Context, user *entity.User) error { } _, err = tx.ExecContext(ctx, ` - INSERT INTO users (id, email, name, is_active, email_verified_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, user.ID, user.Email, user.Name, user.IsActive, ptrToNullTime(emailVerifiedAt), user.CreatedAt, user.UpdatedAt) + INSERT INTO users (id, email, name, is_active, email_verified_at, created_at, updated_at, password) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, user.ID, user.Email, user.Name, user.IsActive, ptrToNullTime(emailVerifiedAt), user.CreatedAt, user.UpdatedAt, user.Password) if err != nil { return fmt.Errorf("insert user: %w", err) } diff --git a/internal/authentication/interfaces/http/handlers_test.go b/internal/authentication/interfaces/http/handlers_test.go index 45a4e63..8679d89 100644 --- a/internal/authentication/interfaces/http/handlers_test.go +++ b/internal/authentication/interfaces/http/handlers_test.go @@ -4,13 +4,18 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" "github.com/IDTS-LAB/go-codebase/internal/authentication/application/command" + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/query" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/google/uuid" ) type mockHandler struct { @@ -248,3 +253,868 @@ func TestResendVerification_Success(t *testing.T) { t.Error("expected meta null") } } + +func TestRegister_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.RegisterUserCommand{}, &mockHandler{ + result: &entity.User{Email: "test@example.com"}, + }) + + body := map[string]string{ + "email": "test@example.com", + "password": "password123", + "name": "Test User", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Register(w, r) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } + if resp["meta"] != nil { + t.Error("expected meta null") + } +} + +func TestRegister_ValidationError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + body := map[string]string{ + "email": "", + "password": "password123", + "name": "Test User", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Register(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestRegister_InvalidBody(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader([]byte("{not json"))) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Register(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestRegister_EmailAlreadyExists(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.RegisterUserCommand{}, &mockHandler{ + err: command.ErrEmailAlreadyExists, + }) + + body := map[string]string{ + "email": "existing@example.com", + "password": "password123", + "name": "Test User", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Register(w, r) + + if w.Code != http.StatusConflict { + t.Fatalf("expected status 409, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogin_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.LoginQuery{}, &mockHandler{ + result: &entity.User{}, + }) + cmdBus.Register(command.GenerateTokensCommand{}, &mockHandler{ + result: &command.TokenPair{ + AccessToken: "access-token", + RefreshToken: "refresh-token", + ExpiresIn: 900, + }, + }) + + body := map[string]string{ + "email": "test@example.com", + "password": "password123", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } +} + +func TestLogin_ValidationError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + body := map[string]string{ + "email": "", + "password": "password123", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogin_InvalidBody(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader([]byte("{not json"))) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogin_InvalidCredentials(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.LoginQuery{}, &mockHandler{ + err: query.ErrInvalidCredentials, + }) + + body := map[string]string{ + "email": "wrong@example.com", + "password": "wrongpassword", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogin_AccountDisabled(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.LoginQuery{}, &mockHandler{ + err: query.ErrAccountDisabled, + }) + + body := map[string]string{ + "email": "disabled@example.com", + "password": "password123", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogin_AccountLocked(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.LoginQuery{}, &mockHandler{ + err: query.ErrAccountLocked, + }) + + body := map[string]string{ + "email": "locked@example.com", + "password": "password123", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } + if resp["error"].(map[string]interface{})["code"] != "ACCOUNT_LOCKED" { + t.Errorf("expected ACCOUNT_LOCKED, got %v", resp["error"].(map[string]interface{})["code"]) + } +} + +func TestLogin_EmailNotVerified(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.LoginQuery{}, &mockHandler{ + err: query.ErrEmailNotVerified, + }) + + body := map[string]string{ + "email": "unverified@example.com", + "password": "password123", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } + if resp["error"].(map[string]interface{})["code"] != "EMAIL_NOT_VERIFIED" { + t.Errorf("expected EMAIL_NOT_VERIFIED, got %v", resp["error"].(map[string]interface{})["code"]) + } +} + +func TestRefreshToken_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.RefreshTokenCommand{}, &mockHandler{ + result: &command.TokenPair{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + ExpiresIn: 900, + }, + }) + + body := map[string]string{"refresh_token": "valid-refresh-token"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.RefreshToken(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } +} + +func TestRefreshToken_ValidationError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + body := map[string]string{"refresh_token": ""} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.RefreshToken(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestRefreshToken_InvalidBody(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewReader([]byte("{not json"))) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.RefreshToken(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestRefreshToken_InvalidToken(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.RefreshTokenCommand{}, &mockHandler{ + err: command.ErrInvalidRefreshToken, + }) + + body := map[string]string{"refresh_token": "invalid-token"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.RefreshToken(w, r) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogout_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.LogoutCommand{}, &mockHandler{}) + + body := map[string]string{"refresh_token": "valid-refresh-token"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/logout", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Logout(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } +} + +func TestLogout_InvalidBody(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/logout", bytes.NewReader([]byte("{not json"))) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Logout(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogoutAll_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.LogoutAllCommand{}, &mockHandler{}) + + userID := uuid.New() + r := httptest.NewRequest(http.MethodPost, "/auth/logout-all", nil) + r = r.WithContext(context.WithValue(r.Context(), middleware.UserIDKey, userID.String())) + w := httptest.NewRecorder() + h.LogoutAll(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } +} + +func TestLogoutAll_NoUserID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/logout-all", nil) + w := httptest.NewRecorder() + h.LogoutAll(w, r) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestLogoutAll_InvalidUserID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/logout-all", nil) + r = r.WithContext(context.WithValue(r.Context(), middleware.UserIDKey, "not-a-uuid")) + w := httptest.NewRecorder() + h.LogoutAll(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestMe_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + userID := uuid.New() + r := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + ctx := context.WithValue(r.Context(), middleware.UserIDKey, userID.String()) + ctx = context.WithValue(ctx, middleware.UserEmailKey, "test@example.com") + r = r.WithContext(ctx) + w := httptest.NewRecorder() + h.Me(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } +} + +func TestMe_NoUserID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + w := httptest.NewRecorder() + h.Me(w, r) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } +} + +func TestRegister_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.RegisterUserCommand{}, &mockHandler{ + err: errors.New("db error"), + }) + + body := map[string]string{ + "email": "test@example.com", + "password": "password123", + "name": "Test User", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Register(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} + +func TestLogin_UnexpectedError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.LoginQuery{}, &mockHandler{ + err: errors.New("unexpected"), + }) + + body := map[string]string{ + "email": "test@example.com", + "password": "password123", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} + +func TestLogin_TokenGenerationError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.LoginQuery{}, &mockHandler{ + result: &entity.User{}, + }) + cmdBus.Register(command.GenerateTokensCommand{}, &mockHandler{ + err: errors.New("token generation failed"), + }) + + body := map[string]string{ + "email": "test@example.com", + "password": "password123", + } + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Login(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} + +func TestRefreshToken_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.RefreshTokenCommand{}, &mockHandler{ + err: errors.New("db error"), + }) + + body := map[string]string{"refresh_token": "some-refresh-token"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.RefreshToken(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} + +func TestLogout_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.LogoutCommand{}, &mockHandler{ + err: errors.New("db error"), + }) + + body := map[string]string{"refresh_token": "some-refresh-token"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/logout", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Logout(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} + +func TestLogoutAll_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + userID := uuid.New() + cmdBus.Register(command.LogoutAllCommand{}, &mockHandler{ + err: errors.New("db error"), + }) + + r := httptest.NewRequest(http.MethodPost, "/auth/logout-all", nil) + r = r.WithContext(context.WithValue(r.Context(), middleware.UserIDKey, userID.String())) + w := httptest.NewRecorder() + h.LogoutAll(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} + +func TestVerifyEmail_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.VerifyEmailCommand{}, &mockHandler{ + err: errors.New("unexpected error"), + }) + + r := httptest.NewRequest(http.MethodGet, "/auth/verify-email?token=some-token", nil) + w := httptest.NewRecorder() + h.VerifyEmail(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} + +func TestResetPassword_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.ResetPasswordCommand{}, &mockHandler{ + err: errors.New("unexpected error"), + }) + + body := map[string]string{"token": "some-token", "new_password": "newpassword123"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/reset-password", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ResetPassword(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data nil") + } +} diff --git a/internal/authentication/tests/auth_handler_integration_test.go b/internal/authentication/tests/auth_handler_integration_test.go new file mode 100644 index 0000000..f012f6d --- /dev/null +++ b/internal/authentication/tests/auth_handler_integration_test.go @@ -0,0 +1,340 @@ +package tests + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/command" + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/query" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + authRepo "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/persistence" + httpHandler "github.com/IDTS-LAB/go-codebase/internal/authentication/interfaces/http" + "github.com/IDTS-LAB/go-codebase/internal/infrastructure/auth" + "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/google/uuid" +) + +type testHandler struct { + handler *httpHandler.Handler + eventBus *events.InMemoryEventBus +} + +func setupTestHandler(t *testing.T, db *sql.DB) *testHandler { + t.Helper() + + cfg := &config.Config{} + cfg.Auth.JWTSecret = "test-secret-for-integration-tests" + cfg.Auth.JWTExpiration = 3600 + + tokenService := auth.NewJWTTokenService(cfg) + userRepo := authRepo.NewUserRepository(db) + refreshRepo := authRepo.NewRefreshTokenRepository(db) + bus := events.NewInMemoryEventBus() + generateTokensHandler := command.NewGenerateTokensHandler(refreshRepo, tokenService) + + cmdBus := cqrs.NewInMemoryCommandBus() + qryBus := cqrs.NewInMemoryQueryBus() + + cmdBus.Register(command.RegisterUserCommand{}, command.NewRegisterUserHandler(userRepo, bus)) + cmdBus.Register(command.GenerateTokensCommand{}, generateTokensHandler) + cmdBus.Register(command.VerifyEmailCommand{}, command.NewVerifyEmailHandler(userRepo, bus)) + cmdBus.Register(command.ForgotPasswordCommand{}, command.NewForgotPasswordHandler(userRepo, bus)) + cmdBus.Register(command.ResetPasswordCommand{}, command.NewResetPasswordHandler(userRepo, refreshRepo)) + cmdBus.Register(command.RefreshTokenCommand{}, command.NewRefreshTokenHandler(refreshRepo, userRepo, generateTokensHandler)) + cmdBus.Register(command.LogoutCommand{}, command.NewLogoutHandler(refreshRepo)) + cmdBus.Register(command.LogoutAllCommand{}, command.NewLogoutAllHandler(refreshRepo)) + + qryBus.Register(query.LoginQuery{}, query.NewLoginHandler(userRepo)) + + v := validator.New() + h := httpHandler.NewHandler(cmdBus, qryBus, v) + + return &testHandler{handler: h, eventBus: bus} +} + +func postJSON(_ *testing.T, hf func(http.ResponseWriter, *http.Request), body []byte) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + hf(rr, req) + return rr +} + +func getQuery(_ *testing.T, hf func(http.ResponseWriter, *http.Request), query string) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?"+query, nil) + hf(rr, req) + return rr +} + +func registerAndVerify(t *testing.T, th *testHandler, email, password, name string) { + t.Helper() + tokenCh := make(chan string, 1) + th.eventBus.Subscribe(event.UserRegisteredEvent, func(ctx context.Context, evt events.Event) error { + payload := evt.Payload.(event.UserRegistered) + tokenCh <- payload.VerificationToken + return nil + }) + body := []byte(fmt.Sprintf(`{"email":"%s","password":"%s","name":"%s"}`, email, password, name)) + rr := postJSON(t, th.handler.Register, body) + if rr.Code != http.StatusCreated { + t.Fatalf("register expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + verifyToken := <-tokenCh + rr = getQuery(t, th.handler.VerifyEmail, "token="+verifyToken) + if rr.Code != http.StatusOK { + t.Fatalf("verify expected 200, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder) utils.APIResponse { + t.Helper() + var resp utils.APIResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + return resp +} + +func TestRegister_Success(t *testing.T) { + th := setupTestHandler(t, db) + + email := "handler-register-success-" + uuid.New().String()[:8] + "@example.com" + rr := postJSON(t, th.handler.Register, []byte(fmt.Sprintf(`{"email":"%s","password":"password123","name":"Test User"}`, email))) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if !resp.Success { + t.Error("expected success=true") + } +} + +func TestRegister_DuplicateEmail(t *testing.T) { + email := "handler-register-dup-" + uuid.New().String()[:8] + "@example.com" + th := setupTestHandler(t, db) + + body := []byte(fmt.Sprintf(`{"email":"%s","password":"password123","name":"First"}`, email)) + rr := postJSON(t, th.handler.Register, body) + if rr.Code != http.StatusCreated { + t.Fatalf("first register expected 201, got %d", rr.Code) + } + + rr = postJSON(t, th.handler.Register, body) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if resp.Success { + t.Error("expected success=false") + } + if resp.Error == nil || resp.Error.Code != "CONFLICT" { + t.Errorf("expected CONFLICT error code, got %+v", resp.Error) + } +} + +func TestRegister_InvalidBody(t *testing.T) { + th := setupTestHandler(t, db) + + rr := postJSON(t, th.handler.Register, []byte(`not-json`)) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestRegister_ValidationError(t *testing.T) { + th := setupTestHandler(t, db) + + rr := postJSON(t, th.handler.Register, []byte(`{"email":"","password":"password123","name":"Test"}`)) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if resp.Success { + t.Error("expected success=false") + } +} + +func TestLogin_Success(t *testing.T) { + email := "handler-login-success-" + uuid.New().String()[:8] + "@example.com" + password := "password123" + th := setupTestHandler(t, db) + + registerAndVerify(t, th, email, password, "Login Test") + + rr := postJSON(t, th.handler.Login, []byte(fmt.Sprintf(`{"email":"%s","password":"%s"}`, email, password))) + if rr.Code != http.StatusOK { + t.Fatalf("login expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if !resp.Success { + t.Fatal("expected success=true") + } + data, ok := resp.Data.(map[string]interface{}) + if !ok { + t.Fatal("data is not a map") + } + if data["access_token"] == "" { + t.Error("expected non-empty access_token") + } + if data["refresh_token"] == "" { + t.Error("expected non-empty refresh_token") + } + if data["token_type"] != "Bearer" { + t.Errorf("expected Bearer token_type, got %v", data["token_type"]) + } +} + +func TestLogin_InvalidCredentials(t *testing.T) { + email := "handler-login-invalid-" + uuid.New().String()[:8] + "@example.com" + password := "password123" + th := setupTestHandler(t, db) + + registerAndVerify(t, th, email, password, "Login Invalid") + + rr := postJSON(t, th.handler.Login, []byte(fmt.Sprintf(`{"email":"%s","password":"wrongpass"}`, email))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if resp.Success { + t.Error("expected success=false") + } +} + +func TestLogin_AccountDisabled(t *testing.T) { + email := "handler-login-disabled-" + uuid.New().String()[:8] + "@example.com" + password := "password123" + th := setupTestHandler(t, db) + + registerAndVerify(t, th, email, password, "Disabled") + + userRepo := authRepo.NewUserRepository(db) + user, err := userRepo.GetByEmail(context.Background(), email) + if err != nil { + t.Fatalf("get user: %v", err) + } + user.IsActive = false + if err := userRepo.Update(context.Background(), user); err != nil { + t.Fatalf("update user: %v", err) + } + + rr := postJSON(t, th.handler.Login, []byte(fmt.Sprintf(`{"email":"%s","password":"%s"}`, email, password))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if resp.Success { + t.Error("expected success=false") + } + if resp.Error == nil || resp.Error.Code != "UNAUTHORIZED" { + t.Errorf("expected UNAUTHORIZED error, got %+v", resp.Error) + } +} + +func TestVerifyEmail(t *testing.T) { + email := "handler-verify-email-" + uuid.New().String()[:8] + "@example.com" + th := setupTestHandler(t, db) + + tokenCh := make(chan string, 1) + th.eventBus.Subscribe(event.UserRegisteredEvent, func(ctx context.Context, evt events.Event) error { + payload := evt.Payload.(event.UserRegistered) + tokenCh <- payload.VerificationToken + return nil + }) + + rr := postJSON(t, th.handler.Register, []byte(fmt.Sprintf(`{"email":"%s","password":"password123","name":"Verify"}`, email))) + if rr.Code != http.StatusCreated { + t.Fatalf("register expected 201, got %d", rr.Code) + } + + verifyToken := <-tokenCh + + rr = getQuery(t, th.handler.VerifyEmail, "token="+verifyToken) + if rr.Code != http.StatusOK { + t.Fatalf("verify expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if !resp.Success { + t.Error("expected success=true") + } +} + +func TestForgotPassword(t *testing.T) { + email := "handler-forgot-password-" + uuid.New().String()[:8] + "@example.com" + th := setupTestHandler(t, db) + + tokenCh := make(chan string, 1) + th.eventBus.Subscribe(event.UserRegisteredEvent, func(ctx context.Context, evt events.Event) error { + payload := evt.Payload.(event.UserRegistered) + tokenCh <- payload.VerificationToken + return nil + }) + + rr := postJSON(t, th.handler.Register, []byte(fmt.Sprintf(`{"email":"%s","password":"password123","name":"Forgot"}`, email))) + if rr.Code != http.StatusCreated { + t.Fatalf("register expected 201, got %d", rr.Code) + } + <-tokenCh + + rr = postJSON(t, th.handler.ForgotPassword, []byte(fmt.Sprintf(`{"email":"%s"}`, email))) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if !resp.Success { + t.Error("expected success=true") + } +} + +func TestResetPassword(t *testing.T) { + email := "handler-reset-password-" + uuid.New().String()[:8] + "@example.com" + newPassword := "newpassword123" + th := setupTestHandler(t, db) + + verifyCh := make(chan string, 1) + th.eventBus.Subscribe(event.UserRegisteredEvent, func(ctx context.Context, evt events.Event) error { + payload := evt.Payload.(event.UserRegistered) + verifyCh <- payload.VerificationToken + return nil + }) + + rr := postJSON(t, th.handler.Register, []byte(fmt.Sprintf(`{"email":"%s","password":"password123","name":"Reset"}`, email))) + if rr.Code != http.StatusCreated { + t.Fatalf("register expected 201, got %d", rr.Code) + } + <-verifyCh + + resetCh := make(chan string, 1) + th.eventBus.Subscribe(event.PasswordResetRequestedEvent, func(ctx context.Context, evt events.Event) error { + payload := evt.Payload.(event.PasswordResetRequested) + resetCh <- payload.ResetToken + return nil + }) + + rr = postJSON(t, th.handler.ForgotPassword, []byte(fmt.Sprintf(`{"email":"%s"}`, email))) + if rr.Code != http.StatusOK { + t.Fatalf("forgot password expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + resetToken := <-resetCh + + rr = postJSON(t, th.handler.ResetPassword, []byte(fmt.Sprintf(`{"token":"%s","new_password":"%s"}`, resetToken, newPassword))) + if rr.Code != http.StatusOK { + t.Fatalf("reset password expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + resp := decodeResponse(t, rr) + if !resp.Success { + t.Error("expected success=true") + } +} diff --git a/internal/authentication/tests/auth_integration_test.go b/internal/authentication/tests/auth_integration_test.go new file mode 100644 index 0000000..b942342 --- /dev/null +++ b/internal/authentication/tests/auth_integration_test.go @@ -0,0 +1,193 @@ +package tests + +import ( + "context" + "database/sql" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + authRepo "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/persistence" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/testhelper" + "github.com/google/uuid" +) + +var db *sql.DB + +func TestMain(m *testing.M) { + var cleanup func() + db, cleanup = testhelper.SetupTestDB(m) + code := m.Run() + cleanup() + os.Exit(code) +} + +func newTestUser(email, name string) *entity.User { + now := domain.NewEntity() + return &entity.User{ + Entity: now, + Email: email, + Password: "$2a$10$test_hash_value", + Name: name, + IsActive: true, + } +} + +func TestAuthUserRepository_CreateAndGet(t *testing.T) { + repo := authRepo.NewUserRepository(db) + user := newTestUser("create-get-"+uuid.New().String()[:8]+"@example.com", "Test User") + + err := repo.Create(context.Background(), user) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := repo.GetByID(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Email != user.Email { + t.Errorf("expected email %s, got %s", user.Email, got.Email) + } + if got.Name != user.Name { + t.Errorf("expected name %s, got %s", user.Name, got.Name) + } +} + +func TestAuthUserRepository_GetByEmail(t *testing.T) { + repo := authRepo.NewUserRepository(db) + email := "find-by-email-" + uuid.New().String()[:8] + "@example.com" + user := newTestUser(email, "Find Me") + repo.Create(context.Background(), user) + + got, err := repo.GetByEmail(context.Background(), email) + if err != nil { + t.Fatalf("GetByEmail: %v", err) + } + if got.ID != user.ID { + t.Errorf("expected id %s, got %s", user.ID, got.ID) + } +} + +func TestAuthUserRepository_GetByEmail_NotFound(t *testing.T) { + repo := authRepo.NewUserRepository(db) + _, err := repo.GetByEmail(context.Background(), "nonexistent@example.com") + if err == nil { + t.Fatal("expected error for nonexistent email") + } +} + +func TestAuthUserRepository_Update(t *testing.T) { + repo := authRepo.NewUserRepository(db) + user := newTestUser("update-test-"+uuid.New().String()[:8]+"@example.com", "Before") + repo.Create(context.Background(), user) + + user.Name = "After" + user.Email = "updated-email-" + uuid.New().String()[:8] + "@example.com" + user.Touch() + err := repo.Update(context.Background(), user) + if err != nil { + t.Fatalf("Update: %v", err) + } + + got, _ := repo.GetByID(context.Background(), user.ID) + if got.Name != "After" { + t.Errorf("expected name 'After', got %s", got.Name) + } +} + +func TestAuthRefreshTokenRepository_CreateAndGet(t *testing.T) { + userRepo := authRepo.NewUserRepository(db) + tokenRepo := authRepo.NewRefreshTokenRepository(db) + + user := newTestUser("token-user-"+uuid.New().String()[:8]+"@example.com", "Token User") + userRepo.Create(context.Background(), user) + + token := entity.NewRefreshToken(user.ID, "token-hash-value-"+uuid.New().String(), time.Now().Add(30*24*time.Hour)) + + err := tokenRepo.Create(context.Background(), token) + if err != nil { + t.Fatalf("Create token: %v", err) + } + + got, err := tokenRepo.GetByToken(context.Background(), token.Token) + if err != nil { + t.Fatalf("GetByToken: %v", err) + } + if got.UserID != user.ID { + t.Errorf("expected userID %s, got %s", user.ID, got.UserID) + } +} + +func TestAuthRefreshTokenRepository_Revoke(t *testing.T) { + userRepo := authRepo.NewUserRepository(db) + tokenRepo := authRepo.NewRefreshTokenRepository(db) + + user := newTestUser("revoke-test-"+uuid.New().String()[:8]+"@example.com", "Revoke User") + userRepo.Create(context.Background(), user) + + token := entity.NewRefreshToken(user.ID, "revoke-token-hash-"+uuid.New().String(), time.Now().Add(30*24*time.Hour)) + tokenRepo.Create(context.Background(), token) + + err := tokenRepo.Revoke(context.Background(), token.Token) + if err != nil { + t.Fatalf("Revoke: %v", err) + } + + got, _ := tokenRepo.GetByToken(context.Background(), token.Token) + if !got.IsRevoked() { + t.Error("expected token to be revoked") + } +} + +func TestAuthRefreshTokenRepository_RevokeAllByUserID(t *testing.T) { + userRepo := authRepo.NewUserRepository(db) + tokenRepo := authRepo.NewRefreshTokenRepository(db) + + user := newTestUser("revoke-all-test-"+uuid.New().String()[:8]+"@example.com", "Revoke All") + userRepo.Create(context.Background(), user) + + token1 := entity.NewRefreshToken(user.ID, "token1-hash-"+uuid.New().String(), time.Now().Add(30*24*time.Hour)) + token2 := entity.NewRefreshToken(user.ID, "token2-hash-"+uuid.New().String(), time.Now().Add(30*24*time.Hour)) + tokenRepo.Create(context.Background(), token1) + tokenRepo.Create(context.Background(), token2) + + err := tokenRepo.RevokeAllByUserID(context.Background(), user.ID) + if err != nil { + t.Fatalf("RevokeAllByUserID: %v", err) + } + + tokens, _ := tokenRepo.GetByUserID(context.Background(), user.ID) + for _, tkn := range tokens { + if !tkn.IsRevoked() { + t.Error("expected all tokens to be revoked") + } + } +} + +func TestAuthUserRepository_Create_DuplicateEmail(t *testing.T) { + repo := authRepo.NewUserRepository(db) + email := "dup-email-auth-" + uuid.New().String()[:8] + "@example.com" + + user1 := newTestUser(email, "First") + if err := repo.Create(context.Background(), user1); err != nil { + t.Fatalf("first create: %v", err) + } + + user2 := newTestUser(email, "Second") + if err := repo.Create(context.Background(), user2); err == nil { + t.Error("expected error for duplicate email") + } +} + +func TestAuthUserRepository_GetByID_NotFound(t *testing.T) { + repo := authRepo.NewUserRepository(db) + _, err := repo.GetByID(context.Background(), uuid.New()) + if err == nil { + t.Error("expected error for nonexistent user") + } +} diff --git a/internal/authorization/domain/entity/permission_test.go b/internal/authorization/domain/entity/permission_test.go new file mode 100644 index 0000000..6fe4720 --- /dev/null +++ b/internal/authorization/domain/entity/permission_test.go @@ -0,0 +1,17 @@ +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPermission(t *testing.T) { + perm := NewPermission("read:users", "Can read users", "users", "read") + + assert.NotEqual(t, "", perm.ID.String()) + assert.Equal(t, "read:users", perm.Name) + assert.Equal(t, "Can read users", perm.Description) + assert.Equal(t, "users", perm.Resource) + assert.Equal(t, "read", perm.Action) +} diff --git a/internal/authorization/domain/entity/role_permission_test.go b/internal/authorization/domain/entity/role_permission_test.go new file mode 100644 index 0000000..9d0d557 --- /dev/null +++ b/internal/authorization/domain/entity/role_permission_test.go @@ -0,0 +1,18 @@ +package entity + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestNewRolePermission(t *testing.T) { + roleID := uuid.New() + permID := uuid.New() + + rp := NewRolePermission(roleID, permID) + + assert.Equal(t, roleID, rp.RoleID) + assert.Equal(t, permID, rp.PermissionID) +} diff --git a/internal/authorization/domain/entity/role_test.go b/internal/authorization/domain/entity/role_test.go new file mode 100644 index 0000000..62d075d --- /dev/null +++ b/internal/authorization/domain/entity/role_test.go @@ -0,0 +1,22 @@ +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRole(t *testing.T) { + role := NewRole("admin", "Administrator role") + + assert.NotEqual(t, "", role.ID.String()) + assert.Equal(t, "admin", role.Name) + assert.Equal(t, "Administrator role", role.Description) +} + +func TestNewRole_Defaults(t *testing.T) { + role := NewRole("user", "") + + assert.Equal(t, "user", role.Name) + assert.Empty(t, role.Description) +} diff --git a/internal/authorization/domain/entity/user_role_test.go b/internal/authorization/domain/entity/user_role_test.go new file mode 100644 index 0000000..c54b99f --- /dev/null +++ b/internal/authorization/domain/entity/user_role_test.go @@ -0,0 +1,18 @@ +package entity + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestNewUserRole(t *testing.T) { + userID := uuid.New() + roleID := uuid.New() + + ur := NewUserRole(userID, roleID) + + assert.Equal(t, userID, ur.UserID) + assert.Equal(t, roleID, ur.RoleID) +} diff --git a/internal/authorization/interfaces/http/handlers_test.go b/internal/authorization/interfaces/http/handlers_test.go index 438dcdb..61151a8 100644 --- a/internal/authorization/interfaces/http/handlers_test.go +++ b/internal/authorization/interfaces/http/handlers_test.go @@ -94,6 +94,24 @@ func TestCreateRole(t *testing.T) { resp := responseMap(t, w) assert.False(t, resp["success"].(bool)) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + cmdBus.Register(command.CreateRoleCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(dto.CreateRoleRequest{Name: "admin", Description: "Administrator"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/roles", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.CreateRole(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestListRoles(t *testing.T) { @@ -177,6 +195,24 @@ func TestGetRole(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + qBus.Register(query.GetRoleQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.GetRole(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestUpdateRole(t *testing.T) { @@ -276,6 +312,26 @@ func TestCreatePermission(t *testing.T) { resp := responseMap(t, w) assert.False(t, resp["success"].(bool)) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + cmdBus.Register(command.CreatePermissionCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(dto.CreatePermissionRequest{ + Name: "read", Description: "Read users", Resource: "users", Action: "read", + }) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/permissions", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.CreatePermission(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestListPermissions(t *testing.T) { @@ -346,6 +402,24 @@ func TestGetPermission(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + qBus.Register(query.GetPermissionQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.GetPermission(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestUpdatePermission(t *testing.T) { @@ -378,6 +452,28 @@ func TestUpdatePermission(t *testing.T) { resp := responseMap(t, w) assert.True(t, resp["success"].(bool)) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + cmdBus.Register(command.UpdatePermissionCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(dto.UpdatePermissionRequest{ + Name: "write", Description: "Write users", Resource: "users", Action: "write", + }) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/permissions/"+permID.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.UpdatePermission(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestDeletePermission(t *testing.T) { @@ -439,6 +535,24 @@ func TestAssignRole(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + cmdBus.Register(command.AssignRoleCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(dto.AssignRoleRequest{UserID: uuid.New(), RoleID: uuid.New()}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users/"+uuid.New().String()+"/roles", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.AssignRole(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestRemoveRole(t *testing.T) { @@ -462,6 +576,25 @@ func TestRemoveRole(t *testing.T) { resp := responseMap(t, w) assert.True(t, resp["success"].(bool)) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + roleID := uuid.New() + cmdBus.Register(command.UnassignRoleCommand{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/"+userID.String()+"/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"userId": userID.String(), "roleId": roleID.String()}) + + h.RemoveRole(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestGetUserRoles(t *testing.T) { @@ -485,6 +618,24 @@ func TestGetUserRoles(t *testing.T) { resp := responseMap(t, w) assert.True(t, resp["success"].(bool)) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + qBus.Register(query.GetUserRolesQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/"+userID.String()+"/roles", nil) + r = withChiParams(r, map[string]string{"userId": userID.String()}) + + h.GetUserRoles(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestAssignPermission(t *testing.T) { @@ -524,6 +675,24 @@ func TestAssignPermission(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + cmdBus.Register(command.AssignPermissionCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(dto.AssignPermissionRequest{RoleID: uuid.New(), PermissionID: uuid.New()}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/roles/"+uuid.New().String()+"/permissions", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.AssignPermission(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestRemovePermission(t *testing.T) { @@ -547,6 +716,25 @@ func TestRemovePermission(t *testing.T) { resp := responseMap(t, w) assert.True(t, resp["success"].(bool)) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + permID := uuid.New() + cmdBus.Register(command.UnassignPermissionCommand{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/roles/"+roleID.String()+"/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"roleId": roleID.String(), "permissionId": permID.String()}) + + h.RemovePermission(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestGetRolePermissions(t *testing.T) { @@ -570,6 +758,24 @@ func TestGetRolePermissions(t *testing.T) { resp := responseMap(t, w) assert.True(t, resp["success"].(bool)) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + qBus.Register(query.GetRolePermissionsQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles/"+roleID.String()+"/permissions", nil) + r = withChiParams(r, map[string]string{"roleId": roleID.String()}) + + h.GetRolePermissions(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestCheckPermission(t *testing.T) { @@ -627,6 +833,26 @@ func TestCheckPermission(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) + + t.Run("bus error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + qBus.Register(query.CheckPermissionQuery{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(dto.CheckPermissionRequest{Resource: "users", Action: "read"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/check-permission", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withUserID(r, userID) + + h.CheckPermission(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } func TestHandlerErrors(t *testing.T) { @@ -695,4 +921,246 @@ func TestHandlerErrors(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code) }) + + t.Run("delete role invalid UUID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/roles/invalid", nil) + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.DeleteRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("update role not found", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + cmdBus.Register(command.UpdateRoleCommand{}, &mockHandler{err: errors.New("not found")}) + + body, _ := json.Marshal(dto.UpdateRoleRequest{Name: "admin"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/roles/"+roleID.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.UpdateRole(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("create permission bad JSON", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/permissions", bytes.NewReader([]byte(`{invalid json`))) + r.Header.Set("Content-Type", "application/json") + + h.CreatePermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("update permission invalid UUID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/permissions/invalid", bytes.NewReader([]byte(`{"name":"read"}`))) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.UpdatePermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("update permission bad JSON", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/permissions/"+permID.String(), bytes.NewReader([]byte(`{invalid}`))) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.UpdatePermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("delete permission invalid UUID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/permissions/invalid", nil) + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.DeletePermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("delete permission error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + cmdBus.Register(command.DeletePermissionCommand{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.DeletePermission(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("remove role invalid user ID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/invalid/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"userId": "invalid", "roleId": roleID.String()}) + + h.RemoveRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("remove role invalid role ID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/"+userID.String()+"/roles/invalid", nil) + r = withChiParams(r, map[string]string{"userId": userID.String(), "roleId": "invalid"}) + + h.RemoveRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("get user roles invalid user ID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/invalid/roles", nil) + r = withChiParams(r, map[string]string{"userId": "invalid"}) + + h.GetUserRoles(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("remove permission invalid role ID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/roles/invalid/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"roleId": "invalid", "permissionId": permID.String()}) + + h.RemovePermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("remove permission invalid permission ID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/roles/"+roleID.String()+"/permissions/invalid", nil) + r = withChiParams(r, map[string]string{"roleId": roleID.String(), "permissionId": "invalid"}) + + h.RemovePermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("get role permissions invalid role ID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles/invalid/permissions", nil) + r = withChiParams(r, map[string]string{"roleId": "invalid"}) + + h.GetRolePermissions(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("list roles error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + qBus.Register(query.ListRolesQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles", nil) + + h.ListRoles(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("list permissions error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + qBus.Register(query.ListPermissionsQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/permissions", nil) + + h.ListPermissions(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) } diff --git a/internal/authorization/tests/authz_handler_integration_test.go b/internal/authorization/tests/authz_handler_integration_test.go new file mode 100644 index 0000000..4c5c994 --- /dev/null +++ b/internal/authorization/tests/authz_handler_integration_test.go @@ -0,0 +1,624 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/command" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/query" + authzRepo "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence" + authzHttp "github.com/IDTS-LAB/go-codebase/internal/authorization/interfaces/http" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" +) + +type mockEnforcer struct{} + +func (m *mockEnforcer) ReloadPolicies(ctx context.Context) error { return nil } +func (m *mockEnforcer) ReloadUserPolicies(ctx context.Context, userID uuid.UUID) error { return nil } +func (m *mockEnforcer) Enforce(userID uuid.UUID, resource, action string) (bool, error) { + return true, nil +} + +type apiResponse struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func setupHandler() *authzHttp.Handler { + roleRepo := authzRepo.NewRoleRepository(db, &tenantfilter.Config{}) + permRepo := authzRepo.NewPermissionRepository(db, &tenantfilter.Config{}) + userRoleRepo := authzRepo.NewUserRoleRepository(db) + rolePermRepo := authzRepo.NewRolePermissionRepository(db) + enf := &mockEnforcer{} + + cmdBus := cqrs.NewInMemoryCommandBus() + cmdBus.Register(command.CreateRoleCommand{}, command.NewCreateRoleHandler(roleRepo)) + cmdBus.Register(command.UpdateRoleCommand{}, command.NewUpdateRoleHandler(roleRepo)) + cmdBus.Register(command.DeleteRoleCommand{}, command.NewDeleteRoleHandler(roleRepo)) + cmdBus.Register(command.CreatePermissionCommand{}, command.NewCreatePermissionHandler(permRepo)) + cmdBus.Register(command.UpdatePermissionCommand{}, command.NewUpdatePermissionHandler(permRepo)) + cmdBus.Register(command.DeletePermissionCommand{}, command.NewDeletePermissionHandler(permRepo)) + cmdBus.Register(command.AssignRoleCommand{}, command.NewAssignRoleHandler(roleRepo, userRoleRepo, enf)) + cmdBus.Register(command.UnassignRoleCommand{}, command.NewUnassignRoleHandler(userRoleRepo, enf)) + cmdBus.Register(command.AssignPermissionCommand{}, command.NewAssignPermissionHandler(roleRepo, permRepo, rolePermRepo, enf)) + cmdBus.Register(command.UnassignPermissionCommand{}, command.NewUnassignPermissionHandler(rolePermRepo, enf)) + + queryBus := cqrs.NewInMemoryQueryBus() + queryBus.Register(query.GetRoleQuery{}, query.NewGetRoleHandler(roleRepo)) + queryBus.Register(query.ListRolesQuery{}, query.NewListRolesHandler(roleRepo)) + queryBus.Register(query.GetPermissionQuery{}, query.NewGetPermissionHandler(permRepo)) + queryBus.Register(query.ListPermissionsQuery{}, query.NewListPermissionsHandler(permRepo)) + queryBus.Register(query.GetUserRolesQuery{}, query.NewGetUserRolesHandler(userRoleRepo)) + queryBus.Register(query.GetRolePermissionsQuery{}, query.NewGetRolePermissionsHandler(rolePermRepo)) + queryBus.Register(query.CheckPermissionQuery{}, query.NewCheckPermissionHandler(enf)) + + v := validator.New() + return authzHttp.NewHandler(cmdBus, queryBus, v) +} + +func withChiParams(r *http.Request, params map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func decodeResponse(t *testing.T, body []byte) apiResponse { + t.Helper() + var resp apiResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode response: %v\nbody: %s", err, string(body)) + } + return resp +} + +func TestHandler_CreateAndGetRole(t *testing.T) { + h := setupHandler() + roleName := fmt.Sprintf("h-create-get-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s","description":"Test role desc"}`, roleName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + resp := decodeResponse(t, w.Body.Bytes()) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + if !resp.Success { + t.Fatal("expected success") + } + + var createdRole struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.Unmarshal(resp.Data, &createdRole); err != nil { + t.Fatalf("unmarshal role: %v", err) + } + if createdRole.Name != roleName { + t.Errorf("expected name %q, got %q", roleName, createdRole.Name) + } + + getReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/roles/"+createdRole.ID, nil) + getReq = withChiParams(getReq, map[string]string{"id": createdRole.ID}) + w2 := httptest.NewRecorder() + h.GetRole(w2, getReq) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + var gotRole struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + } + resp2 := decodeResponse(t, w2.Body.Bytes()) + if err := json.Unmarshal(resp2.Data, &gotRole); err != nil { + t.Fatalf("unmarshal role: %v", err) + } + if gotRole.Name != roleName { + t.Errorf("expected name %q, got %q", roleName, gotRole.Name) + } +} + +func TestHandler_CreateAndListRoles(t *testing.T) { + h := setupHandler() + roleName := fmt.Sprintf("h-list-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s"}`, roleName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/roles", nil) + w2 := httptest.NewRecorder() + h.ListRoles(w2, listReq) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + resp := decodeResponse(t, w2.Body.Bytes()) + var roles []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Data, &roles); err != nil { + t.Fatalf("unmarshal roles: %v", err) + } + var found bool + for _, r := range roles { + if r.Name == roleName { + found = true + break + } + } + if !found { + t.Errorf("role %q not found in list", roleName) + } +} + +func TestHandler_CreateUpdateAndGetRole(t *testing.T) { + h := setupHandler() + roleName := fmt.Sprintf("h-upd-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s","description":"Original"}`, roleName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + var created struct { + ID string `json:"id"` + } + json.Unmarshal(resp.Data, &created) + + updatedName := roleName + "-updated" + updateBody := fmt.Sprintf(`{"name":"%s","description":"Updated desc"}`, updatedName) + putReq := httptest.NewRequest(http.MethodPut, "/auth/sessions/roles/"+created.ID, bytes.NewReader([]byte(updateBody))) + putReq.Header.Set("Content-Type", "application/json") + putReq = withChiParams(putReq, map[string]string{"id": created.ID}) + w2 := httptest.NewRecorder() + h.UpdateRole(w2, putReq) + + if w2.Code != http.StatusOK { + t.Fatalf("update: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/roles/"+created.ID, nil) + getReq = withChiParams(getReq, map[string]string{"id": created.ID}) + w3 := httptest.NewRecorder() + h.GetRole(w3, getReq) + if w3.Code != http.StatusOK { + t.Fatalf("get after update: %d %s", w3.Code, w3.Body.String()) + } + + resp3 := decodeResponse(t, w3.Body.Bytes()) + var updated struct { + Name string `json:"name"` + Description string `json:"description"` + } + json.Unmarshal(resp3.Data, &updated) + if updated.Name != updatedName { + t.Errorf("expected name %q, got %q", updatedName, updated.Name) + } + if updated.Description != "Updated desc" { + t.Errorf("expected desc 'Updated desc', got %q", updated.Description) + } +} + +func TestHandler_CreateDeleteAndGetRole(t *testing.T) { + h := setupHandler() + roleName := fmt.Sprintf("h-del-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s"}`, roleName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + var created struct { + ID string `json:"id"` + } + json.Unmarshal(resp.Data, &created) + + delReq := httptest.NewRequest(http.MethodDelete, "/auth/sessions/roles/"+created.ID, nil) + delReq = withChiParams(delReq, map[string]string{"id": created.ID}) + w2 := httptest.NewRecorder() + h.DeleteRole(w2, delReq) + if w2.Code != http.StatusOK { + t.Fatalf("delete: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/roles/"+created.ID, nil) + getReq = withChiParams(getReq, map[string]string{"id": created.ID}) + w3 := httptest.NewRecorder() + h.GetRole(w3, getReq) + resp3 := decodeResponse(t, w3.Body.Bytes()) + if resp3.Success { + t.Error("expected error after delete, got success") + } +} + +func TestHandler_CreateAndGetPermission(t *testing.T) { + h := setupHandler() + permName := fmt.Sprintf("h-perm-get-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s","resource":"users","action":"read"}`, permName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/permissions", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreatePermission(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + var created struct { + ID string `json:"id"` + Name string `json:"name"` + Resource string `json:"resource"` + Action string `json:"action"` + } + json.Unmarshal(resp.Data, &created) + if created.Name != permName { + t.Errorf("expected name %q, got %q", permName, created.Name) + } + + getReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/permissions/"+created.ID, nil) + getReq = withChiParams(getReq, map[string]string{"id": created.ID}) + w2 := httptest.NewRecorder() + h.GetPermission(w2, getReq) + if w2.Code != http.StatusOK { + t.Fatalf("get: %d %s", w2.Code, w2.Body.String()) + } + resp2 := decodeResponse(t, w2.Body.Bytes()) + var got struct { + Name string `json:"name"` + } + json.Unmarshal(resp2.Data, &got) + if got.Name != permName { + t.Errorf("expected name %q, got %q", permName, got.Name) + } +} + +func TestHandler_CreateAndListPermissions(t *testing.T) { + h := setupHandler() + permName := fmt.Sprintf("h-perm-list-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s","resource":"users","action":"read"}`, permName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/permissions", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreatePermission(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/permissions", nil) + w2 := httptest.NewRecorder() + h.ListPermissions(w2, listReq) + if w2.Code != http.StatusOK { + t.Fatalf("list: %d %s", w2.Code, w2.Body.String()) + } + + resp := decodeResponse(t, w2.Body.Bytes()) + var perms []struct { + Name string `json:"name"` + } + json.Unmarshal(resp.Data, &perms) + var found bool + for _, p := range perms { + if p.Name == permName { + found = true + break + } + } + if !found { + t.Errorf("permission %q not found in list", permName) + } +} + +func TestHandler_CreateUpdateAndGetPermission(t *testing.T) { + h := setupHandler() + permName := fmt.Sprintf("h-perm-upd-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s","resource":"users","action":"read"}`, permName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/permissions", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreatePermission(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + var created struct { + ID string `json:"id"` + } + json.Unmarshal(resp.Data, &created) + + updatedName := permName + "-v2" + updBody := fmt.Sprintf(`{"name":"%s","resource":"users","action":"write"}`, updatedName) + putReq := httptest.NewRequest(http.MethodPut, "/auth/sessions/permissions/"+created.ID, bytes.NewReader([]byte(updBody))) + putReq.Header.Set("Content-Type", "application/json") + putReq = withChiParams(putReq, map[string]string{"id": created.ID}) + w2 := httptest.NewRecorder() + h.UpdatePermission(w2, putReq) + if w2.Code != http.StatusOK { + t.Fatalf("update: %d %s", w2.Code, w2.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/permissions/"+created.ID, nil) + getReq = withChiParams(getReq, map[string]string{"id": created.ID}) + w3 := httptest.NewRecorder() + h.GetPermission(w3, getReq) + if w3.Code != http.StatusOK { + t.Fatalf("get after update: %d %s", w3.Code, w3.Body.String()) + } + + resp3 := decodeResponse(t, w3.Body.Bytes()) + var updated struct { + Name string `json:"name"` + Action string `json:"action"` + } + json.Unmarshal(resp3.Data, &updated) + if updated.Name != updatedName { + t.Errorf("expected name %q, got %q", updatedName, updated.Name) + } + if updated.Action != "write" { + t.Errorf("expected action 'write', got %q", updated.Action) + } +} + +func TestHandler_CreateDeleteAndGetPermission(t *testing.T) { + h := setupHandler() + permName := fmt.Sprintf("h-perm-del-%s", uuid.New().String()) + + body := fmt.Sprintf(`{"name":"%s","resource":"users","action":"read"}`, permName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/permissions", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreatePermission(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + var created struct { + ID string `json:"id"` + } + json.Unmarshal(resp.Data, &created) + + delReq := httptest.NewRequest(http.MethodDelete, "/auth/sessions/permissions/"+created.ID, nil) + delReq = withChiParams(delReq, map[string]string{"id": created.ID}) + w2 := httptest.NewRecorder() + h.DeletePermission(w2, delReq) + if w2.Code != http.StatusOK { + t.Fatalf("delete: %d %s", w2.Code, w2.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/permissions/"+created.ID, nil) + getReq = withChiParams(getReq, map[string]string{"id": created.ID}) + w3 := httptest.NewRecorder() + h.GetPermission(w3, getReq) + resp3 := decodeResponse(t, w3.Body.Bytes()) + if resp3.Success { + t.Error("expected error after delete, got success") + } +} + +func TestHandler_AssignAndGetRolePermissions(t *testing.T) { + h := setupHandler() + roleName := fmt.Sprintf("h-rp-%s", uuid.New().String()) + permName := fmt.Sprintf("h-rp-perm-%s", uuid.New().String()) + + roleBody := fmt.Sprintf(`{"name":"%s"}`, roleName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(roleBody))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create role: %d %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + var role struct { + ID string `json:"id"` + Name string `json:"name"` + } + json.Unmarshal(resp.Data, &role) + + permBody := fmt.Sprintf(`{"name":"%s","resource":"docs","action":"read"}`, permName) + req2 := httptest.NewRequest(http.MethodPost, "/auth/sessions/permissions", bytes.NewReader([]byte(permBody))) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + h.CreatePermission(w2, req2) + if w2.Code != http.StatusCreated { + t.Fatalf("create perm: %d %s", w2.Code, w2.Body.String()) + } + resp2 := decodeResponse(t, w2.Body.Bytes()) + var perm struct { + ID string `json:"id"` + } + json.Unmarshal(resp2.Data, &perm) + + assignBody := fmt.Sprintf(`{"role_id":"%s","permission_id":"%s"}`, role.ID, perm.ID) + assignReq := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles/"+role.ID+"/permissions", bytes.NewReader([]byte(assignBody))) + assignReq.Header.Set("Content-Type", "application/json") + assignReq = withChiParams(assignReq, map[string]string{"roleId": role.ID}) + w3 := httptest.NewRecorder() + h.AssignPermission(w3, assignReq) + if w3.Code != http.StatusOK { + t.Fatalf("assign perm: %d %s", w3.Code, w3.Body.String()) + } + + getPermsReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/roles/"+role.ID+"/permissions", nil) + getPermsReq = withChiParams(getPermsReq, map[string]string{"roleId": role.ID}) + w4 := httptest.NewRecorder() + h.GetRolePermissions(w4, getPermsReq) + if w4.Code != http.StatusOK { + t.Fatalf("get role perms: %d %s", w4.Code, w4.Body.String()) + } + + resp4 := decodeResponse(t, w4.Body.Bytes()) + var perms []struct { + Name string `json:"name"` + } + json.Unmarshal(resp4.Data, &perms) + var found bool + for _, p := range perms { + if p.Name == permName { + found = true + break + } + } + if !found { + t.Errorf("permission %q not found in role permissions", permName) + } +} + +func TestHandler_AssignAndRemoveUserRole(t *testing.T) { + h := setupHandler() + roleName := fmt.Sprintf("h-ur-%s", uuid.New().String()) + + roleBody := fmt.Sprintf(`{"name":"%s"}`, roleName) + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(roleBody))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create role: %d %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + var role struct { + ID string `json:"id"` + } + json.Unmarshal(resp.Data, &role) + + userID := uuid.New() + assignBody := fmt.Sprintf(`{"user_id":"%s","role_id":"%s"}`, userID.String(), role.ID) + assignReq := httptest.NewRequest(http.MethodPost, "/auth/sessions/users/"+userID.String()+"/roles", bytes.NewReader([]byte(assignBody))) + assignReq.Header.Set("Content-Type", "application/json") + assignReq = withChiParams(assignReq, map[string]string{"userId": userID.String()}) + w2 := httptest.NewRecorder() + h.AssignRole(w2, assignReq) + if w2.Code != http.StatusOK { + t.Fatalf("assign role: %d %s", w2.Code, w2.Body.String()) + } + + getURReq := httptest.NewRequest(http.MethodGet, "/auth/sessions/users/"+userID.String()+"/roles", nil) + getURReq = withChiParams(getURReq, map[string]string{"userId": userID.String()}) + w3 := httptest.NewRecorder() + h.GetUserRoles(w3, getURReq) + if w3.Code != http.StatusOK { + t.Fatalf("get user roles: %d %s", w3.Code, w3.Body.String()) + } + + resp3 := decodeResponse(t, w3.Body.Bytes()) + var userRoles []struct { + Name string `json:"name"` + } + json.Unmarshal(resp3.Data, &userRoles) + var found bool + for _, ur := range userRoles { + if ur.Name == roleName { + found = true + break + } + } + if !found { + t.Errorf("role %q not found in user roles", roleName) + } + + removeReq := httptest.NewRequest(http.MethodDelete, "/auth/sessions/users/"+userID.String()+"/roles/"+role.ID, nil) + removeReq = withChiParams(removeReq, map[string]string{"userId": userID.String(), "roleId": role.ID}) + w4 := httptest.NewRecorder() + h.RemoveRole(w4, removeReq) + if w4.Code != http.StatusOK { + t.Fatalf("remove role: %d %s", w4.Code, w4.Body.String()) + } + + getURReq2 := httptest.NewRequest(http.MethodGet, "/auth/sessions/users/"+userID.String()+"/roles", nil) + getURReq2 = withChiParams(getURReq2, map[string]string{"userId": userID.String()}) + w5 := httptest.NewRecorder() + h.GetUserRoles(w5, getURReq2) + resp5 := decodeResponse(t, w5.Body.Bytes()) + var userRolesAfter []struct { + Name string `json:"name"` + } + json.Unmarshal(resp5.Data, &userRolesAfter) + for _, ur := range userRolesAfter { + if ur.Name == roleName { + t.Error("role still assigned after remove") + break + } + } +} + +func TestHandler_ValidationErrorOnCreateRole(t *testing.T) { + h := setupHandler() + + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + if resp.Error == nil { + t.Fatal("expected error body") + } + if resp.Error.Code != "VALIDATION_ERROR" { + t.Errorf("expected VALIDATION_ERROR, got %q", resp.Error.Code) + } +} + +func TestHandler_BadJSONOnCreateRole(t *testing.T) { + h := setupHandler() + + req := httptest.NewRequest(http.MethodPost, "/auth/sessions/roles", bytes.NewReader([]byte(`{invalid`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.CreateRole(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + resp := decodeResponse(t, w.Body.Bytes()) + if resp.Error == nil { + t.Fatal("expected error body") + } + if resp.Error.Code != "VALIDATION_ERROR" { + t.Errorf("expected VALIDATION_ERROR, got %q", resp.Error.Code) + } + if resp.Error.Message != "invalid request body" { + t.Errorf("expected 'invalid request body', got %q", resp.Error.Message) + } +} diff --git a/internal/authorization/tests/authz_integration_test.go b/internal/authorization/tests/authz_integration_test.go new file mode 100644 index 0000000..d1796f7 --- /dev/null +++ b/internal/authorization/tests/authz_integration_test.go @@ -0,0 +1,279 @@ +package tests + +import ( + "context" + "database/sql" + "fmt" + "os" + "testing" + + _ "github.com/lib/pq" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + authzPersistence "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" + "github.com/IDTS-LAB/go-codebase/internal/testhelper" + "github.com/google/uuid" +) + +var db *sql.DB + +func TestMain(m *testing.M) { + var cleanup func() + db, cleanup = testhelper.SetupTestDB(m) + code := m.Run() + cleanup() + os.Exit(code) +} + +func TestRoleRepository_CreateAndGetByID(t *testing.T) { + repo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + + role := entity.NewRole(fmt.Sprintf("admin-role-%s", uuid.New().String()[:8]), "Administrator") + err := repo.Create(context.Background(), role) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := repo.GetByID(context.Background(), role.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Name != role.Name { + t.Errorf("expected name %s, got %s", role.Name, got.Name) + } + if got.Description != role.Description { + t.Errorf("expected description %s, got %s", role.Description, got.Description) + } +} + +func TestRoleRepository_GetByName(t *testing.T) { + repo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + + roleName := fmt.Sprintf("editor-role-%s", uuid.New().String()[:8]) + role := entity.NewRole(roleName, "Editor role") + repo.Create(context.Background(), role) + + got, err := repo.GetByName(context.Background(), roleName) + if err != nil { + t.Fatalf("GetByName: %v", err) + } + if got.ID != role.ID { + t.Errorf("expected id %s, got %s", role.ID, got.ID) + } +} + +func TestRoleRepository_GetAll(t *testing.T) { + repo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + + repo.Create(context.Background(), entity.NewRole(fmt.Sprintf("role-a-%s", uuid.New().String()[:8]), "A")) + repo.Create(context.Background(), entity.NewRole(fmt.Sprintf("role-b-%s", uuid.New().String()[:8]), "B")) + + roles, _, _, _, _, err := repo.GetAll(context.Background(), nil, 20) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(roles) < 2 { + t.Errorf("expected at least 2 roles, got %d", len(roles)) + } +} + +func TestRoleRepository_Update(t *testing.T) { + repo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + + beforeName := fmt.Sprintf("before-role-%s", uuid.New().String()[:8]) + afterName := fmt.Sprintf("after-role-%s", uuid.New().String()[:8]) + role := entity.NewRole(beforeName, "Before description") + repo.Create(context.Background(), role) + + role.Name = afterName + role.Description = "After description" + role.Touch() + err := repo.Update(context.Background(), role) + if err != nil { + t.Fatalf("Update: %v", err) + } + + got, _ := repo.GetByID(context.Background(), role.ID) + if got.Name != afterName { + t.Errorf("expected name '%s', got %s", afterName, got.Name) + } +} + +func TestRoleRepository_Delete(t *testing.T) { + repo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + + role := entity.NewRole(fmt.Sprintf("delete-role-%s", uuid.New().String()[:8]), "To be deleted") + repo.Create(context.Background(), role) + + err := repo.Delete(context.Background(), role.ID) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = repo.GetByID(context.Background(), role.ID) + if err == nil { + t.Error("expected error after delete") + } +} + +func TestPermissionRepository_CreateAndGetByID(t *testing.T) { + repo := authzPersistence.NewPermissionRepository(db, &tenantfilter.Config{}) + + perm := entity.NewPermission(fmt.Sprintf("read-users-%s", uuid.New().String()[:8]), "Read users", "users", "read") + err := repo.Create(context.Background(), perm) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := repo.GetByID(context.Background(), perm.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Name != perm.Name { + t.Errorf("expected name %s, got %s", perm.Name, got.Name) + } + if got.Resource != "users" { + t.Errorf("expected resource 'users', got %s", got.Resource) + } +} + +func TestPermissionRepository_GetAll(t *testing.T) { + repo := authzPersistence.NewPermissionRepository(db, &tenantfilter.Config{}) + + repo.Create(context.Background(), entity.NewPermission(fmt.Sprintf("perm-a-%s", uuid.New().String()[:8]), "", "res", "read")) + repo.Create(context.Background(), entity.NewPermission(fmt.Sprintf("perm-b-%s", uuid.New().String()[:8]), "", "res", "write")) + + perms, _, _, _, _, err := repo.GetAll(context.Background(), nil, 20) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(perms) < 2 { + t.Errorf("expected at least 2 permissions, got %d", len(perms)) + } +} + +func TestPermissionRepository_Delete(t *testing.T) { + repo := authzPersistence.NewPermissionRepository(db, &tenantfilter.Config{}) + + perm := entity.NewPermission(fmt.Sprintf("del-perm-%s", uuid.New().String()[:8]), "", "res", "read") + repo.Create(context.Background(), perm) + + err := repo.Delete(context.Background(), perm.ID) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = repo.GetByID(context.Background(), perm.ID) + if err == nil { + t.Error("expected error after delete") + } +} + +func TestUserRoleRepository_AssignAndGetByUserID(t *testing.T) { + roleRepo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + urRepo := authzPersistence.NewUserRoleRepository(db) + + role := entity.NewRole(fmt.Sprintf("user-role-test-%s", uuid.New().String()[:8]), "Test") + roleRepo.Create(context.Background(), role) + + userID := uuid.New() + err := urRepo.Assign(context.Background(), entity.NewUserRole(userID, role.ID)) + if err != nil { + t.Fatalf("Assign: %v", err) + } + + roles, err := urRepo.GetRolesByUserID(context.Background(), userID) + if err != nil { + t.Fatalf("GetRolesByUserID: %v", err) + } + if len(roles) != 1 { + t.Fatalf("expected 1 role, got %d", len(roles)) + } + if roles[0].ID != role.ID { + t.Errorf("expected role id %s, got %s", role.ID, roles[0].ID) + } +} + +func TestUserRoleRepository_Remove(t *testing.T) { + roleRepo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + urRepo := authzPersistence.NewUserRoleRepository(db) + + role := entity.NewRole(fmt.Sprintf("remove-role-test-%s", uuid.New().String()[:8]), "To remove") + roleRepo.Create(context.Background(), role) + + userID := uuid.New() + urRepo.Assign(context.Background(), entity.NewUserRole(userID, role.ID)) + + err := urRepo.Remove(context.Background(), userID, role.ID) + if err != nil { + t.Fatalf("Remove: %v", err) + } + + roles, _ := urRepo.GetRolesByUserID(context.Background(), userID) + if len(roles) != 0 { + t.Error("expected no roles after remove") + } +} + +func TestRolePermissionRepository_AssignAndGetByRoleID(t *testing.T) { + roleRepo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + permRepo := authzPersistence.NewPermissionRepository(db, &tenantfilter.Config{}) + rpRepo := authzPersistence.NewRolePermissionRepository(db) + + role := entity.NewRole(fmt.Sprintf("rp-role-test-%s", uuid.New().String()[:8]), "Role for RP test") + perm := entity.NewPermission(fmt.Sprintf("rp-perm-test-%s", uuid.New().String()[:8]), "", "res", "read") + roleRepo.Create(context.Background(), role) + permRepo.Create(context.Background(), perm) + + err := rpRepo.Assign(context.Background(), entity.NewRolePermission(role.ID, perm.ID)) + if err != nil { + t.Fatalf("Assign: %v", err) + } + + perms, err := rpRepo.GetPermissionsByRoleID(context.Background(), role.ID) + if err != nil { + t.Fatalf("GetPermissionsByRoleID: %v", err) + } + if len(perms) != 1 { + t.Fatalf("expected 1 permission, got %d", len(perms)) + } +} + +func TestRolePermissionRepository_Remove(t *testing.T) { + roleRepo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + permRepo := authzPersistence.NewPermissionRepository(db, &tenantfilter.Config{}) + rpRepo := authzPersistence.NewRolePermissionRepository(db) + + role := entity.NewRole(fmt.Sprintf("rp-remove-test-%s", uuid.New().String()[:8]), "RP remove") + perm := entity.NewPermission(fmt.Sprintf("rp-remove-perm-%s", uuid.New().String()[:8]), "", "res", "read") + roleRepo.Create(context.Background(), role) + permRepo.Create(context.Background(), perm) + + rpRepo.Assign(context.Background(), entity.NewRolePermission(role.ID, perm.ID)) + + err := rpRepo.Remove(context.Background(), role.ID, perm.ID) + if err != nil { + t.Fatalf("Remove: %v", err) + } + + perms, _ := rpRepo.GetPermissionsByRoleID(context.Background(), role.ID) + if len(perms) != 0 { + t.Error("expected no permissions after remove") + } +} + +func TestRoleRepository_Create_DuplicateName(t *testing.T) { + repo := authzPersistence.NewRoleRepository(db, &tenantfilter.Config{}) + name := fmt.Sprintf("unique-role-name-test-%s", uuid.New().String()[:8]) + + r1 := entity.NewRole(name, "First") + if err := repo.Create(context.Background(), r1); err != nil { + t.Fatalf("first create: %v", err) + } + + r2 := entity.NewRole(name, "Second") + if err := repo.Create(context.Background(), r2); err == nil { + t.Error("expected error for duplicate role name") + } +} diff --git a/internal/core/domain/aggregate_test.go b/internal/core/domain/aggregate_test.go new file mode 100644 index 0000000..f8063c5 --- /dev/null +++ b/internal/core/domain/aggregate_test.go @@ -0,0 +1,83 @@ +package domain + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +type testEvent struct { + BaseEvent +} + +func TestNewAggregateRoot(t *testing.T) { + a := NewAggregateRoot() + + assert.NotEqual(t, uuid.Nil, a.ID) + assert.False(t, a.CreatedAt.IsZero()) + assert.False(t, a.HasEvents()) +} + +func TestRecordEvent(t *testing.T) { + a := NewAggregateRoot() + original := a.UpdatedAt + + time.Sleep(time.Millisecond) + a.RecordEvent(testEvent{}) + + assert.True(t, a.HasEvents()) + assert.Len(t, a.Events(), 1) + assert.True(t, a.UpdatedAt.After(original)) +} + +func TestPullEvents(t *testing.T) { + a := NewAggregateRoot() + a.RecordEvent(testEvent{}) + + events := a.PullEvents() + + assert.Len(t, events, 1) + assert.False(t, a.HasEvents()) + assert.Empty(t, a.Events()) +} + +func TestClearEvents(t *testing.T) { + a := NewAggregateRoot() + a.RecordEvent(testEvent{}) + + a.ClearEvents() + + assert.False(t, a.HasEvents()) + assert.Empty(t, a.Events()) +} + +func TestHasEvents(t *testing.T) { + t.Run("no events", func(t *testing.T) { + a := NewAggregateRoot() + assert.False(t, a.HasEvents()) + }) + + t.Run("with events", func(t *testing.T) { + a := NewAggregateRoot() + a.RecordEvent(testEvent{}) + assert.True(t, a.HasEvents()) + }) + + t.Run("after pull", func(t *testing.T) { + a := NewAggregateRoot() + a.RecordEvent(testEvent{}) + a.PullEvents() + assert.False(t, a.HasEvents()) + }) +} + +func TestTouchWith(t *testing.T) { + a := NewAggregateRoot() + expected := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + a.TouchWith(expected) + + assert.Equal(t, expected, a.UpdatedAt) +} diff --git a/internal/core/domain/entity_test.go b/internal/core/domain/entity_test.go new file mode 100644 index 0000000..a8fd079 --- /dev/null +++ b/internal/core/domain/entity_test.go @@ -0,0 +1,86 @@ +package domain + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestNewEntity(t *testing.T) { + e := NewEntity() + + assert.NotEqual(t, uuid.Nil, e.ID) + assert.False(t, e.CreatedAt.IsZero()) + assert.False(t, e.UpdatedAt.IsZero()) + assert.True(t, e.CreatedAt.Equal(e.UpdatedAt)) + assert.Nil(t, e.DeletedAt) +} + +func TestTouch(t *testing.T) { + e := NewEntity() + original := e.UpdatedAt + + time.Sleep(time.Millisecond) + e.Touch() + + assert.True(t, e.UpdatedAt.After(original)) +} + +func TestSoftDelete(t *testing.T) { + e := NewEntity() + assert.Nil(t, e.DeletedAt) + assert.False(t, e.IsDeleted()) + + e.SoftDelete() + + assert.NotNil(t, e.DeletedAt) + assert.True(t, e.IsDeleted()) + assert.True(t, e.UpdatedAt.Equal(*e.DeletedAt)) +} + +func TestIsDeleted(t *testing.T) { + t.Run("not deleted", func(t *testing.T) { + e := NewEntity() + assert.False(t, e.IsDeleted()) + }) + + t.Run("deleted", func(t *testing.T) { + e := NewEntity() + e.SoftDelete() + assert.True(t, e.IsDeleted()) + }) +} + +func TestEquals(t *testing.T) { + id := uuid.New() + + t.Run("same ID", func(t *testing.T) { + a := Entity{ID: id} + b := Entity{ID: id} + assert.True(t, a.Equals(&b)) + }) + + t.Run("different ID", func(t *testing.T) { + a := Entity{ID: uuid.New()} + b := Entity{ID: uuid.New()} + assert.False(t, a.Equals(&b)) + }) + + t.Run("nil receiver", func(t *testing.T) { + var a *Entity + b := Entity{ID: uuid.New()} + assert.False(t, a.Equals(&b)) + }) + + t.Run("nil other", func(t *testing.T) { + a := Entity{ID: uuid.New()} + assert.False(t, a.Equals(nil)) + }) + + t.Run("both nil", func(t *testing.T) { + var a *Entity + assert.False(t, a.Equals(nil)) + }) +} diff --git a/internal/core/domain/errors_test.go b/internal/core/domain/errors_test.go new file mode 100644 index 0000000..1b25923 --- /dev/null +++ b/internal/core/domain/errors_test.go @@ -0,0 +1,58 @@ +package domain + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDomainError(t *testing.T) { + err := NewDomainError(ErrNotFound, "NOT_FOUND", "entity was not found") + + assert.Equal(t, "entity was not found", err.Error()) + assert.ErrorIs(t, err, ErrNotFound) + assert.Equal(t, "NOT_FOUND", err.Code) +} + +func TestDomainErrorMessage(t *testing.T) { + t.Run("with message", func(t *testing.T) { + err := NewDomainError(ErrNotFound, "NOT_FOUND", "custom message") + assert.Equal(t, "custom message", err.Error()) + }) + + t.Run("without message", func(t *testing.T) { + err := &DomainError{Err: ErrNotFound, Code: "NOT_FOUND"} + assert.Equal(t, ErrNotFound.Error(), err.Error()) + }) +} + +func TestIsNotFound(t *testing.T) { + t.Run("not found error", func(t *testing.T) { + assert.True(t, IsNotFound(ErrNotFound)) + }) + + t.Run("wrapped not found", func(t *testing.T) { + err := NewDomainError(ErrNotFound, "NOT_FOUND", "") + assert.True(t, IsNotFound(err)) + }) + + t.Run("other error", func(t *testing.T) { + assert.False(t, IsNotFound(errors.New("something else"))) + }) +} + +func TestIsConflict(t *testing.T) { + t.Run("conflict error", func(t *testing.T) { + assert.True(t, IsConflict(ErrConflict)) + }) + + t.Run("wrapped conflict", func(t *testing.T) { + err := NewDomainError(ErrConflict, "CONFLICT", "") + assert.True(t, IsConflict(err)) + }) + + t.Run("other error", func(t *testing.T) { + assert.False(t, IsConflict(errors.New("something else"))) + }) +} diff --git a/internal/core/domain/events_test.go b/internal/core/domain/events_test.go new file mode 100644 index 0000000..de53053 --- /dev/null +++ b/internal/core/domain/events_test.go @@ -0,0 +1,19 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBaseEventEventType(t *testing.T) { + e := BaseEvent{Type: "test.event"} + + assert.Equal(t, "test.event", e.EventType()) +} + +func TestBaseEventOccurredAt(t *testing.T) { + e := BaseEvent{Timestamp: "2024-01-01T00:00:00Z"} + + assert.Equal(t, "2024-01-01T00:00:00Z", e.OccurredAt()) +} diff --git a/internal/core/domain/identifier_test.go b/internal/core/domain/identifier_test.go new file mode 100644 index 0000000..eaca54e --- /dev/null +++ b/internal/core/domain/identifier_test.go @@ -0,0 +1,120 @@ +package domain + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestNewIdentifier(t *testing.T) { + id := NewIdentifier() + + assert.False(t, id.IsZero()) + assert.NotEqual(t, uuid.Nil, id.UUID()) +} + +func TestIdentifierFromUUID(t *testing.T) { + uid := uuid.New() + id := IdentifierFromUUID(uid) + + assert.Equal(t, uid, id.UUID()) +} + +func TestIdentifierFromString(t *testing.T) { + t.Run("valid string", func(t *testing.T) { + uid := uuid.New() + id, err := IdentifierFromString(uid.String()) + + assert.NoError(t, err) + assert.Equal(t, uid, id.UUID()) + }) + + t.Run("invalid string", func(t *testing.T) { + _, err := IdentifierFromString("not-a-uuid") + + assert.Error(t, err) + }) +} + +func TestIdentifierUUID(t *testing.T) { + uid := uuid.New() + id := IdentifierFromUUID(uid) + + assert.Equal(t, uid, id.UUID()) +} + +func TestIdentifierString(t *testing.T) { + uid := uuid.New() + id := IdentifierFromUUID(uid) + + assert.Equal(t, uid.String(), id.String()) +} + +func TestIdentifierIsZero(t *testing.T) { + t.Run("nil UUID", func(t *testing.T) { + id := Identifier{} + assert.True(t, id.IsZero()) + }) + + t.Run("non-nil UUID", func(t *testing.T) { + id := NewIdentifier() + assert.False(t, id.IsZero()) + }) +} + +func TestIdentifierEquals(t *testing.T) { + uid := uuid.New() + + t.Run("equal", func(t *testing.T) { + a := IdentifierFromUUID(uid) + b := IdentifierFromUUID(uid) + assert.True(t, a.Equals(b)) + }) + + t.Run("not equal", func(t *testing.T) { + a := IdentifierFromUUID(uuid.New()) + b := IdentifierFromUUID(uuid.New()) + assert.False(t, a.Equals(b)) + }) +} + +func TestIdentifierMarshalText(t *testing.T) { + uid := uuid.New() + id := IdentifierFromUUID(uid) + + data, err := id.MarshalText() + + assert.NoError(t, err) + assert.Equal(t, []byte(uid.String()), data) +} + +func TestIdentifierUnmarshalText(t *testing.T) { + t.Run("invalid text", func(t *testing.T) { + var id Identifier + err := id.UnmarshalText([]byte("bad")) + assert.Error(t, err) + }) +} + +func TestIdentifierValue(t *testing.T) { + uid := uuid.New() + id := IdentifierFromUUID(uid) + + v, err := id.Value() + + assert.NoError(t, err) + s, ok := v.(string) + assert.True(t, ok) + assert.Equal(t, uid.String(), s) +} + +func TestIdentifierValueAndScan(t *testing.T) { + uid := uuid.New() + id := IdentifierFromUUID(uid) + + v, err := id.Value() + assert.NoError(t, err) + _, ok := v.(string) + assert.True(t, ok) +} diff --git a/internal/core/domain/timestamp_test.go b/internal/core/domain/timestamp_test.go new file mode 100644 index 0000000..1f4a03f --- /dev/null +++ b/internal/core/domain/timestamp_test.go @@ -0,0 +1,177 @@ +package domain + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewTimestamp(t *testing.T) { + ts := NewTimestamp() + + assert.False(t, ts.IsZero()) +} + +func TestTimestampFrom(t *testing.T) { + now := time.Now().UTC() + ts := TimestampFrom(now) + + assert.Equal(t, now, ts.Time()) +} + +func TestTimestampTime(t *testing.T) { + now := time.Now().UTC() + ts := TimestampFrom(now) + + assert.Equal(t, now, ts.Time()) +} + +func TestTimestampString(t *testing.T) { + now := time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC) + ts := TimestampFrom(now) + + assert.Equal(t, "2024-01-02T15:04:05Z", ts.String()) +} + +func TestTimestampIsZero(t *testing.T) { + t.Run("zero value", func(t *testing.T) { + ts := Timestamp{} + assert.True(t, ts.IsZero()) + }) + + t.Run("non-zero value", func(t *testing.T) { + ts := NewTimestamp() + assert.False(t, ts.IsZero()) + }) +} + +func TestTimestampEquals(t *testing.T) { + now := time.Now().UTC() + + t.Run("equal", func(t *testing.T) { + a := TimestampFrom(now) + b := TimestampFrom(now) + assert.True(t, a.Equals(b)) + }) + + t.Run("not equal", func(t *testing.T) { + a := TimestampFrom(now) + b := TimestampFrom(now.Add(time.Hour)) + assert.False(t, a.Equals(b)) + }) +} + +func TestTimestampBefore(t *testing.T) { + earlier := TimestampFrom(time.Now()) + later := TimestampFrom(time.Now().Add(time.Hour)) + + assert.True(t, earlier.Before(later)) + assert.False(t, later.Before(earlier)) +} + +func TestTimestampAfter(t *testing.T) { + earlier := TimestampFrom(time.Now()) + later := TimestampFrom(time.Now().Add(time.Hour)) + + assert.True(t, later.After(earlier)) + assert.False(t, earlier.After(later)) +} + +func TestTimestampAdd(t *testing.T) { + base := TimestampFrom(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + result := base.Add(2 * time.Hour) + + assert.Equal(t, time.Date(2024, 1, 1, 2, 0, 0, 0, time.UTC), result.Time()) +} + +func TestTimestampMarshalText(t *testing.T) { + now := time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC) + ts := TimestampFrom(now) + + data, err := ts.MarshalText() + + assert.NoError(t, err) + assert.Equal(t, []byte("2024-01-02T15:04:05Z"), data) +} + +func TestTimestampUnmarshalText(t *testing.T) { + t.Run("valid text", func(t *testing.T) { + var ts Timestamp + + err := ts.UnmarshalText([]byte("2024-01-02T15:04:05Z")) + + assert.NoError(t, err) + assert.Equal(t, 2024, ts.Time().Year()) + assert.Equal(t, time.January, ts.Time().Month()) + }) + + t.Run("invalid text", func(t *testing.T) { + var ts Timestamp + + err := ts.UnmarshalText([]byte("bad")) + + assert.Error(t, err) + }) +} + +func TestTimestampValue(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + ts := TimestampFrom(now) + + v, err := ts.Value() + + assert.NoError(t, err) + assert.Equal(t, now, v) +} + +func TestTimestampScan(t *testing.T) { + t.Run("time.Time input", func(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + var ts Timestamp + + err := ts.Scan(now) + + assert.NoError(t, err) + assert.Equal(t, now, ts.Time()) + }) + + t.Run("byte slice input", func(t *testing.T) { + var ts Timestamp + + err := ts.Scan([]byte("2024-01-02T15:04:05Z")) + + assert.NoError(t, err) + assert.Equal(t, 2024, ts.Time().Year()) + }) + + t.Run("string input", func(t *testing.T) { + var ts Timestamp + + err := ts.Scan("2024-01-02T15:04:05Z") + + assert.NoError(t, err) + assert.Equal(t, 2024, ts.Time().Year()) + }) + + t.Run("unsupported type", func(t *testing.T) { + var ts Timestamp + + err := ts.Scan(42) + + assert.Error(t, err) + }) +} + +func TestTimestampValueAndScan(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + ts := TimestampFrom(now) + + v, err := ts.Value() + assert.NoError(t, err) + + var scanned Timestamp + err = scanned.Scan(v) + assert.NoError(t, err) + assert.Equal(t, now, scanned.Time()) +} diff --git a/internal/tenant/domain/entity/tenant_test.go b/internal/tenant/domain/entity/tenant_test.go new file mode 100644 index 0000000..8376c7b --- /dev/null +++ b/internal/tenant/domain/entity/tenant_test.go @@ -0,0 +1,65 @@ +package entity + +import ( + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestTenant_Creation(t *testing.T) { + settings := json.RawMessage(`{"theme": "dark"}`) + now := time.Now().UTC().Truncate(time.Second) + + tenant := Tenant{ + ID: uuid.New(), + Name: "Acme Corp", + Slug: "acme-corp", + Settings: settings, + IsActive: true, + CreatedAt: now, + UpdatedAt: now, + } + + assert.NotEqual(t, uuid.Nil, tenant.ID) + assert.Equal(t, "Acme Corp", tenant.Name) + assert.Equal(t, "acme-corp", tenant.Slug) + assert.Nil(t, tenant.Domain) + assert.Equal(t, settings, tenant.Settings) + assert.True(t, tenant.IsActive) + assert.Equal(t, now, tenant.CreatedAt) + assert.Equal(t, now, tenant.UpdatedAt) +} + +func TestTenant_WithDomain(t *testing.T) { + domain := "acme.com" + tenant := Tenant{ + ID: uuid.New(), + Name: "Acme Corp", + Slug: "acme-corp", + Domain: &domain, + Settings: json.RawMessage(`{}`), + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + assert.NotNil(t, tenant.Domain) + assert.Equal(t, "acme.com", *tenant.Domain) +} + +func TestTenant_Inactive(t *testing.T) { + tenant := Tenant{ + ID: uuid.New(), + Name: "Inactive Corp", + Slug: "inactive-corp", + Settings: json.RawMessage(`{}`), + IsActive: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + assert.False(t, tenant.IsActive) +} diff --git a/internal/tenant/interfaces/http/handlers_test.go b/internal/tenant/interfaces/http/handlers_test.go new file mode 100644 index 0000000..c5492c9 --- /dev/null +++ b/internal/tenant/interfaces/http/handlers_test.go @@ -0,0 +1,447 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/command" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/query" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +type mockHandler struct { + result any + err error +} + +func (h *mockHandler) Handle(ctx context.Context, _ any) (any, error) { + return h.result, h.err +} + +func withChiParams(r *http.Request, params map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func TestCreate_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.CreateTenantCommand{}, &mockHandler{ + result: dto.TenantResponse{ID: uuid.New().String(), Name: "Acme Corp", Slug: "acme-corp"}, + }) + + body, _ := json.Marshal(dto.CreateTenantRequest{Name: "Acme Corp", Slug: "acme-corp"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusCreated, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) +} + +func TestCreate_BadJSON(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader([]byte("{invalid"))) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCreate_ValidationError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + body, _ := json.Marshal(dto.CreateTenantRequest{Name: "", Slug: ""}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCreate_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.CreateTenantCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(dto.CreateTenantRequest{Name: "Acme", Slug: "acme"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestCreate_Conflict(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.CreateTenantCommand{}, &mockHandler{ + err: domain.NewDomainError(domain.ErrAlreadyExists, "TENANT_EXISTS", "tenant with this slug already exists"), + }) + + body, _ := json.Marshal(dto.CreateTenantRequest{Name: "Acme", Slug: "acme"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusConflict, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Equal(t, "CONFLICT", resp["error"].(map[string]interface{})["code"]) +} + +func TestList_Success_DefaultLimit(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.ListTenantsQuery{}, &mockHandler{ + result: dto.TenantListResponse{Tenants: []dto.TenantResponse{}, Limit: 20}, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/tenants", nil) + + h.List(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) + assert.NotNil(t, resp["meta"]) +} + +func TestList_Success_WithCursor(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cursor := "next-cursor" + qBus.Register(query.ListTenantsQuery{}, &mockHandler{ + result: dto.TenantListResponse{ + Tenants: []dto.TenantResponse{}, + NextCursor: &cursor, + HasNext: true, + Limit: 10, + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/tenants?cursor=abc&limit=10", nil) + + h.List(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) +} + +func TestList_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + qBus.Register(query.ListTenantsQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/tenants", nil) + + h.List(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestGetByID_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + qBus.Register(query.GetTenantQuery{}, &mockHandler{ + result: dto.TenantResponse{ID: id.String(), Name: "Acme", Slug: "acme"}, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/tenants/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.GetByID(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) +} + +func TestGetByID_InvalidUUID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/tenants/invalid", nil) + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.GetByID(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGetByID_NotFound(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + qBus.Register(query.GetTenantQuery{}, &mockHandler{ + err: domain.NewDomainError(domain.ErrNotFound, "TENANT_NOT_FOUND", "tenant not found"), + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/tenants/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.GetByID(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestGetByID_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + qBus.Register(query.GetTenantQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/tenants/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.GetByID(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestUpdate_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + cmdBus.Register(command.UpdateTenantCommand{}, &mockHandler{ + result: dto.TenantResponse{ID: id.String(), Name: "Updated", Slug: "acme"}, + }) + + name := "Updated" + body, _ := json.Marshal(dto.UpdateTenantRequest{Name: &name}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/tenants/"+id.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Update(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) +} + +func TestUpdate_InvalidUUID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + name := "Updated" + body, _ := json.Marshal(dto.UpdateTenantRequest{Name: &name}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/tenants/invalid", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.Update(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdate_BadJSON(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/tenants/"+id.String(), bytes.NewReader([]byte("{invalid"))) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Update(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdate_NotFound(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + cmdBus.Register(command.UpdateTenantCommand{}, &mockHandler{ + err: domain.NewDomainError(domain.ErrNotFound, "TENANT_NOT_FOUND", "tenant not found"), + }) + + name := "Updated" + body, _ := json.Marshal(dto.UpdateTenantRequest{Name: &name}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/tenants/"+id.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Update(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestUpdate_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + cmdBus.Register(command.UpdateTenantCommand{}, &mockHandler{err: errors.New("db error")}) + + name := "Updated" + body, _ := json.Marshal(dto.UpdateTenantRequest{Name: &name}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/tenants/"+id.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Update(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestDelete_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + cmdBus.Register(command.DeleteTenantCommand{}, &mockHandler{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/tenants/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDelete_InvalidUUID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/tenants/invalid", nil) + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDelete_NotFound(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + cmdBus.Register(command.DeleteTenantCommand{}, &mockHandler{ + err: domain.NewDomainError(domain.ErrNotFound, "TENANT_NOT_FOUND", "tenant not found"), + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/tenants/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestDelete_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + id := uuid.New() + cmdBus.Register(command.DeleteTenantCommand{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/tenants/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} diff --git a/internal/tenant/tests/tenant_handler_integration_test.go b/internal/tenant/tests/tenant_handler_integration_test.go new file mode 100644 index 0000000..fb3c386 --- /dev/null +++ b/internal/tenant/tests/tenant_handler_integration_test.go @@ -0,0 +1,311 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/command" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/query" + tenantRepo "github.com/IDTS-LAB/go-codebase/internal/tenant/infrastructure/persistence" + tenantHttp "github.com/IDTS-LAB/go-codebase/internal/tenant/interfaces/http" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +type apiResponse struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func setupHandler() *tenantHttp.Handler { + repo := tenantRepo.NewTenantRepository(db) + commandBus := cqrs.NewInMemoryCommandBus() + queryBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + + commandBus.Register(command.CreateTenantCommand{}, command.NewCreateTenantHandler(repo)) + commandBus.Register(command.UpdateTenantCommand{}, command.NewUpdateTenantHandler(repo)) + commandBus.Register(command.DeleteTenantCommand{}, command.NewDeleteTenantHandler(repo)) + queryBus.Register(query.GetTenantQuery{}, query.NewGetTenantHandler(repo)) + queryBus.Register(query.ListTenantsQuery{}, query.NewListTenantsHandler(repo)) + + return tenantHttp.NewHandler(commandBus, queryBus, v) +} + +func withChiParams(r *http.Request, params map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func createTenant(t *testing.T, handler *tenantHttp.Handler, name, slug string) *httptest.ResponseRecorder { + t.Helper() + body := dto.CreateTenantRequest{Name: name, Slug: slug} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.Create(w, req) + return w +} + +func parseResponse(t *testing.T, body []byte) apiResponse { + t.Helper() + var resp apiResponse + err := json.Unmarshal(body, &resp) + assert.NoError(t, err) + return resp +} + +func TestTenantHandler_CreateAndGetByID(t *testing.T) { + handler := setupHandler() + + slug := "test-corp-" + uuid.New().String()[:8] + w := createTenant(t, handler, "Test Corp", slug) + assert.Equal(t, http.StatusCreated, w.Code) + + createResp := parseResponse(t, w.Body.Bytes()) + assert.True(t, createResp.Success) + + var tenant dto.TenantResponse + err := json.Unmarshal(createResp.Data, &tenant) + assert.NoError(t, err) + assert.Equal(t, "Test Corp", tenant.Name) + assert.Equal(t, slug, tenant.Slug) + + req := httptest.NewRequest(http.MethodGet, "/tenants/"+tenant.ID, nil) + req = withChiParams(req, map[string]string{"id": tenant.ID}) + w2 := httptest.NewRecorder() + handler.GetByID(w2, req) + + assert.Equal(t, http.StatusOK, w2.Code) + + getResp := parseResponse(t, w2.Body.Bytes()) + assert.True(t, getResp.Success) + + var got dto.TenantResponse + err = json.Unmarshal(getResp.Data, &got) + assert.NoError(t, err) + assert.Equal(t, tenant.Name, got.Name) + assert.Equal(t, tenant.Slug, got.Slug) +} + +func TestTenantHandler_CreateAndList(t *testing.T) { + handler := setupHandler() + + w := createTenant(t, handler, "List Corp", "list-corp-"+uuid.New().String()[:8]) + assert.Equal(t, http.StatusCreated, w.Code) + + createResp := parseResponse(t, w.Body.Bytes()) + var tenant dto.TenantResponse + json.Unmarshal(createResp.Data, &tenant) + + req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100", nil) + w2 := httptest.NewRecorder() + handler.List(w2, req) + + assert.Equal(t, http.StatusOK, w2.Code) + + listResp := parseResponse(t, w2.Body.Bytes()) + assert.True(t, listResp.Success) + + var tenants []dto.TenantResponse + err := json.Unmarshal(listResp.Data, &tenants) + assert.NoError(t, err) + + found := false + for _, tnt := range tenants { + if tnt.ID == tenant.ID { + found = true + assert.Equal(t, "List Corp", tnt.Name) + break + } + } + assert.True(t, found, "created tenant should appear in list") +} + +func TestTenantHandler_CreateAndUpdate(t *testing.T) { + handler := setupHandler() + + w := createTenant(t, handler, "Before Corp", "before-corp-"+uuid.New().String()[:8]) + assert.Equal(t, http.StatusCreated, w.Code) + + createResp := parseResponse(t, w.Body.Bytes()) + var tenant dto.TenantResponse + json.Unmarshal(createResp.Data, &tenant) + + newName := "After Corp" + updateReq := dto.UpdateTenantRequest{Name: &newName} + b, _ := json.Marshal(updateReq) + req := httptest.NewRequest(http.MethodPut, "/tenants/"+tenant.ID, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req = withChiParams(req, map[string]string{"id": tenant.ID}) + w2 := httptest.NewRecorder() + handler.Update(w2, req) + + assert.Equal(t, http.StatusOK, w2.Code) + + updateResp := parseResponse(t, w2.Body.Bytes()) + assert.True(t, updateResp.Success) + + var updated dto.TenantResponse + json.Unmarshal(updateResp.Data, &updated) + assert.Equal(t, newName, updated.Name) + + req3 := httptest.NewRequest(http.MethodGet, "/tenants/"+tenant.ID, nil) + req3 = withChiParams(req3, map[string]string{"id": tenant.ID}) + w3 := httptest.NewRecorder() + handler.GetByID(w3, req3) + + assert.Equal(t, http.StatusOK, w3.Code) + + getResp := parseResponse(t, w3.Body.Bytes()) + var got dto.TenantResponse + json.Unmarshal(getResp.Data, &got) + assert.Equal(t, newName, got.Name) +} + +func TestTenantHandler_CreateAndDelete(t *testing.T) { + handler := setupHandler() + + w := createTenant(t, handler, "Delete Corp", "delete-corp-"+uuid.New().String()[:8]) + assert.Equal(t, http.StatusCreated, w.Code) + + createResp := parseResponse(t, w.Body.Bytes()) + var tenant dto.TenantResponse + json.Unmarshal(createResp.Data, &tenant) + + req := httptest.NewRequest(http.MethodDelete, "/tenants/"+tenant.ID, nil) + req = withChiParams(req, map[string]string{"id": tenant.ID}) + w2 := httptest.NewRecorder() + handler.Delete(w2, req) + + assert.Equal(t, http.StatusOK, w2.Code) + + req3 := httptest.NewRequest(http.MethodGet, "/tenants/"+tenant.ID, nil) + req3 = withChiParams(req3, map[string]string{"id": tenant.ID}) + w3 := httptest.NewRecorder() + handler.GetByID(w3, req3) + + assert.Equal(t, http.StatusNotFound, w3.Code) +} + +func TestTenantHandler_CreateDuplicateSlug(t *testing.T) { + handler := setupHandler() + + slug := "dup-slug-" + uuid.New().String()[:8] + w := createTenant(t, handler, "First", slug) + assert.Equal(t, http.StatusCreated, w.Code) + + w2 := createTenant(t, handler, "Second", slug) + assert.Equal(t, http.StatusConflict, w2.Code) + + resp := parseResponse(t, w2.Body.Bytes()) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "CONFLICT", resp.Error.Code) +} + +func TestTenantHandler_GetByID_NotFound(t *testing.T) { + handler := setupHandler() + + id := uuid.New().String() + req := httptest.NewRequest(http.MethodGet, "/tenants/"+id, nil) + req = withChiParams(req, map[string]string{"id": id}) + w := httptest.NewRecorder() + handler.GetByID(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + + resp := parseResponse(t, w.Body.Bytes()) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "NOT_FOUND", resp.Error.Code) +} + +func TestTenantHandler_Update_NotFound(t *testing.T) { + handler := setupHandler() + + id := uuid.New().String() + newName := "Nope" + body := dto.UpdateTenantRequest{Name: &newName} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/tenants/"+id, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req = withChiParams(req, map[string]string{"id": id}) + w := httptest.NewRecorder() + handler.Update(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + + resp := parseResponse(t, w.Body.Bytes()) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "NOT_FOUND", resp.Error.Code) +} + +func TestTenantHandler_Delete_NotFound(t *testing.T) { + handler := setupHandler() + + id := uuid.New().String() + req := httptest.NewRequest(http.MethodDelete, "/tenants/"+id, nil) + req = withChiParams(req, map[string]string{"id": id}) + w := httptest.NewRecorder() + handler.Delete(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + + resp := parseResponse(t, w.Body.Bytes()) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "NOT_FOUND", resp.Error.Code) +} + +func TestTenantHandler_Create_ValidationError(t *testing.T) { + handler := setupHandler() + + body := dto.CreateTenantRequest{} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.Create(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + resp := parseResponse(t, w.Body.Bytes()) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "VALIDATION_ERROR", resp.Error.Code) +} + +func TestTenantHandler_Create_BadJSON(t *testing.T) { + handler := setupHandler() + + req := httptest.NewRequest(http.MethodPost, "/tenants", bytes.NewReader([]byte("not json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.Create(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + resp := parseResponse(t, w.Body.Bytes()) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "VALIDATION_ERROR", resp.Error.Code) +} diff --git a/internal/tenant/tests/tenant_integration_test.go b/internal/tenant/tests/tenant_integration_test.go new file mode 100644 index 0000000..85a7dd4 --- /dev/null +++ b/internal/tenant/tests/tenant_integration_test.go @@ -0,0 +1,152 @@ +package tests + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/entity" + tenantPersistence "github.com/IDTS-LAB/go-codebase/internal/tenant/infrastructure/persistence" + "github.com/IDTS-LAB/go-codebase/internal/testhelper" + "github.com/google/uuid" +) + +var db *sql.DB + +func TestMain(m *testing.M) { + var cleanup func() + db, cleanup = testhelper.SetupTestDB(m) + code := m.Run() + cleanup() + os.Exit(code) +} + +func newTenant(name, slug string) *entity.Tenant { + now := time.Now().UTC() + return &entity.Tenant{ + ID: uuid.New(), + Name: name, + Slug: slug, + Settings: json.RawMessage("{}"), + IsActive: true, + CreatedAt: now, + UpdatedAt: now, + } +} + +func TestTenantRepository_CreateAndGetByID(t *testing.T) { + repo := tenantPersistence.NewTenantRepository(db) + + slug := "test-corp-" + uuid.New().String()[:8] + tenant := newTenant("Test Corp", slug) + err := repo.Create(context.Background(), tenant) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := repo.GetByID(context.Background(), tenant.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Name != tenant.Name { + t.Errorf("expected name %s, got %s", tenant.Name, got.Name) + } + if got.Slug != tenant.Slug { + t.Errorf("expected slug %s, got %s", tenant.Slug, got.Slug) + } +} + +func TestTenantRepository_GetBySlug(t *testing.T) { + repo := tenantPersistence.NewTenantRepository(db) + + slug := "unique-slug-test-" + uuid.New().String()[:8] + tenant := newTenant("Slug Corp", slug) + repo.Create(context.Background(), tenant) + + got, err := repo.GetBySlug(context.Background(), slug) + if err != nil { + t.Fatalf("GetBySlug: %v", err) + } + if got.ID != tenant.ID { + t.Errorf("expected id %s, got %s", tenant.ID, got.ID) + } +} + +func TestTenantRepository_List(t *testing.T) { + repo := tenantPersistence.NewTenantRepository(db) + + repo.Create(context.Background(), newTenant("List Corp A", "list-a-"+uuid.New().String()[:8])) + repo.Create(context.Background(), newTenant("List Corp B", "list-b-"+uuid.New().String()[:8])) + + tenants, _, _, _, _, err := repo.List(context.Background(), nil, 20) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(tenants) < 2 { + t.Errorf("expected at least 2 tenants, got %d", len(tenants)) + } +} + +func TestTenantRepository_Update(t *testing.T) { + repo := tenantPersistence.NewTenantRepository(db) + + tenant := newTenant("Before Corp", "before-slug-"+uuid.New().String()[:8]) + repo.Create(context.Background(), tenant) + + tenant.Name = "After Corp" + tenant.UpdatedAt = time.Now().UTC() + err := repo.Update(context.Background(), tenant) + if err != nil { + t.Fatalf("Update: %v", err) + } + + got, _ := repo.GetByID(context.Background(), tenant.ID) + if got.Name != "After Corp" { + t.Errorf("expected name 'After Corp', got %s", got.Name) + } +} + +func TestTenantRepository_Delete(t *testing.T) { + repo := tenantPersistence.NewTenantRepository(db) + + tenant := newTenant("Delete Corp", "delete-corp-"+uuid.New().String()[:8]) + repo.Create(context.Background(), tenant) + + err := repo.Delete(context.Background(), tenant.ID) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = repo.GetByID(context.Background(), tenant.ID) + if err == nil { + t.Error("expected error after delete") + } +} + +func TestTenantRepository_GetByID_NotFound(t *testing.T) { + repo := tenantPersistence.NewTenantRepository(db) + _, err := repo.GetByID(context.Background(), uuid.New()) + if err == nil { + t.Error("expected error for nonexistent tenant") + } +} + +func TestTenantRepository_Create_DuplicateSlug(t *testing.T) { + repo := tenantPersistence.NewTenantRepository(db) + slug := "dup-slug-test-" + uuid.New().String()[:8] + + t1 := newTenant("First", slug) + if err := repo.Create(context.Background(), t1); err != nil { + t.Fatalf("first create: %v", err) + } + + t2 := newTenant("Second", slug) + if err := repo.Create(context.Background(), t2); err == nil { + t.Error("expected error for duplicate slug") + } +} diff --git a/internal/testhelper/testdb.go b/internal/testhelper/testdb.go new file mode 100644 index 0000000..6ddaca4 --- /dev/null +++ b/internal/testhelper/testdb.go @@ -0,0 +1,74 @@ +package testhelper + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/pressly/goose/v3" +) + +func migrationsDir() string { + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Dir(filename) + return filepath.Join(dir, "..", "..", "migrations") +} + +func SetupTestDB(m *testing.M) (*sql.DB, func()) { + db, err := sql.Open("postgres", dsnFromEnv()) + if err != nil { + panic(fmt.Sprintf("connect to test DB: %v", err)) + } + if err := db.Ping(); err != nil { + panic(fmt.Sprintf("ping test DB: %v", err)) + } + if err := runMigrations(db); err != nil { + panic(fmt.Sprintf("run migrations: %v", err)) + } + return db, func() { db.Close() } +} + +func dsnFromEnv() string { + host := envOrDefault("DB_HOST", "localhost") + port := envOrDefault("DB_PORT", "5432") + user := envOrDefault("DB_USER", "postgres") + password := envOrDefault("DB_PASSWORD", "postgres") + dbname := envOrDefault("DB_NAME", "codebase_testing") + sslmode := envOrDefault("DB_SSLMODE", "disable") + return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + host, port, user, password, dbname, sslmode) +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func runMigrations(db *sql.DB) error { + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("set dialect: %w", err) + } + mDir := migrationsDir() + if _, err := os.Stat(mDir); os.IsNotExist(err) { + return fmt.Errorf("migrations directory not found at %s", mDir) + } + if err := goose.Up(db, mDir); err != nil { + return fmt.Errorf("goose up: %w", err) + } + return nil +} + +func WithTx(t *testing.T, db *sql.DB, fn func(tx *sql.Tx)) { + t.Helper() + tx, err := db.Begin() + if err != nil { + t.Fatalf("begin tx: %v", err) + } + defer func() { _ = tx.Rollback() }() + fn(tx) +} diff --git a/internal/todo/domain/entity/todo_test.go b/internal/todo/domain/entity/todo_test.go new file mode 100644 index 0000000..b961d80 --- /dev/null +++ b/internal/todo/domain/entity/todo_test.go @@ -0,0 +1,81 @@ +package entity + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestNewTodo(t *testing.T) { + todo := NewTodo("Buy milk", "2% milk") + + assert.NotEqual(t, uuid.Nil, todo.ID) + assert.Equal(t, "Buy milk", todo.Title) + assert.Equal(t, "2% milk", todo.Description) + assert.False(t, todo.Completed) +} + +func TestNewTodo_EmptyDescription(t *testing.T) { + todo := NewTodo("Buy milk", "") + + assert.Equal(t, "Buy milk", todo.Title) + assert.Empty(t, todo.Description) +} + +func TestComplete(t *testing.T) { + todo := NewTodo("Task", "") + todo.Complete() + + assert.True(t, todo.Completed) + assert.True(t, todo.UpdatedAt.After(todo.CreatedAt)) +} + +func TestUpdate(t *testing.T) { + todo := NewTodo("Old Title", "Old Desc") + + todo.Update("New Title", "New Desc") + + assert.Equal(t, "New Title", todo.Title) + assert.Equal(t, "New Desc", todo.Description) +} + +func TestUpdate_Partial(t *testing.T) { + t.Run("empty title keeps old", func(t *testing.T) { + todo := NewTodo("Old Title", "Old Desc") + todo.Update("", "New Desc") + + assert.Equal(t, "Old Title", todo.Title) + assert.Equal(t, "New Desc", todo.Description) + }) + + t.Run("empty description keeps old", func(t *testing.T) { + todo := NewTodo("Old Title", "Old Desc") + todo.Update("New Title", "") + + assert.Equal(t, "New Title", todo.Title) + assert.Equal(t, "Old Desc", todo.Description) + }) +} + +func TestIDString(t *testing.T) { + todo := NewTodo("Task", "") + + assert.Equal(t, todo.ID.String(), todo.IDString()) +} + +func TestTodoIDFromString(t *testing.T) { + t.Run("valid string", func(t *testing.T) { + id := uuid.New() + parsed, err := TodoIDFromString(id.String()) + + assert.NoError(t, err) + assert.Equal(t, id, parsed) + }) + + t.Run("invalid string", func(t *testing.T) { + _, err := TodoIDFromString("invalid") + + assert.Error(t, err) + }) +} diff --git a/internal/todo/domain/specification/todo_specification_test.go b/internal/todo/domain/specification/todo_specification_test.go new file mode 100644 index 0000000..fb8a043 --- /dev/null +++ b/internal/todo/domain/specification/todo_specification_test.go @@ -0,0 +1,52 @@ +package specification + +import ( + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" + "github.com/stretchr/testify/assert" +) + +func TestNotCompletedSpec(t *testing.T) { + spec := NotCompletedSpec{} + + t.Run("not completed returns true", func(t *testing.T) { + todo := entity.NewTodo("Task", "") + assert.True(t, spec.IsSatisfiedBy(todo)) + }) + + t.Run("completed returns false", func(t *testing.T) { + todo := entity.NewTodo("Task", "") + todo.Complete() + assert.False(t, spec.IsSatisfiedBy(todo)) + }) +} + +func TestCompletedSpec(t *testing.T) { + spec := CompletedSpec{} + + t.Run("completed returns true", func(t *testing.T) { + todo := entity.NewTodo("Task", "") + todo.Complete() + assert.True(t, spec.IsSatisfiedBy(todo)) + }) + + t.Run("not completed returns false", func(t *testing.T) { + todo := entity.NewTodo("Task", "") + assert.False(t, spec.IsSatisfiedBy(todo)) + }) +} + +func TestTitleContainsSpec(t *testing.T) { + t.Run("empty substring returns true", func(t *testing.T) { + spec := TitleContainsSpec{Substring: ""} + todo := entity.NewTodo("Task", "") + assert.True(t, spec.IsSatisfiedBy(todo)) + }) + + t.Run("non-empty substring", func(t *testing.T) { + spec := TitleContainsSpec{Substring: "Task"} + todo := entity.NewTodo("Task", "") + assert.True(t, spec.IsSatisfiedBy(todo)) + }) +} diff --git a/internal/todo/tests/todo_handler_integration_test.go b/internal/todo/tests/todo_handler_integration_test.go new file mode 100644 index 0000000..305c5d0 --- /dev/null +++ b/internal/todo/tests/todo_handler_integration_test.go @@ -0,0 +1,352 @@ +package tests + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + _ "github.com/lib/pq" + + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/IDTS-LAB/go-codebase/internal/testhelper" + "github.com/IDTS-LAB/go-codebase/internal/todo/application/command" + "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/todo/application/query" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" + todoPersistence "github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/persistence" + httpHandler "github.com/IDTS-LAB/go-codebase/internal/todo/interfaces/http" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func integrationHandler(t *testing.T) *httpHandler.Handler { + t.Helper() + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + domainSvc := service.NewTodoDomainService(repo) + eventBus := events.NewInMemoryEventBus() + + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + + cmdBus.Register(command.CreateTodoCommand{}, command.NewCreateTodoHandler(domainSvc, eventBus)) + cmdBus.Register(command.UpdateTodoCommand{}, command.NewUpdateTodoHandler(domainSvc, eventBus)) + cmdBus.Register(command.DeleteTodoCommand{}, command.NewDeleteTodoHandler(domainSvc, eventBus)) + cmdBus.Register(command.CompleteTodoCommand{}, command.NewCompleteTodoHandler(domainSvc, eventBus)) + + qBus.Register(query.GetTodoQuery{}, query.NewGetTodoHandler(domainSvc)) + qBus.Register(query.ListTodosQuery{}, query.NewListTodosHandler(domainSvc)) + qBus.Register(query.SearchTodosQuery{}, query.NewSearchTodosHandler(domainSvc)) + + v := validator.New() + return httpHandler.NewHandler(cmdBus, qBus, v) +} + +func TestIntegration_CreateTodoAndGetTodo(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + uniqueTitle := "BuyMilk_" + uuid.New().String() + body, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle, Description: "2% milk"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.CreateTodo(rr, req) + + assert.Equal(t, http.StatusCreated, rr.Code) + var createResp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&createResp) + assert.True(t, createResp["success"].(bool)) + createData := createResp["data"].(map[string]interface{}) + todoID := createData["id"].(string) + assert.Equal(t, uniqueTitle, createData["title"]) + assert.Equal(t, "2% milk", createData["description"]) + assert.Equal(t, false, createData["completed"]) + assert.NotEmpty(t, createData["created_at"]) + assert.NotEmpty(t, createData["updated_at"]) + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/todos/"+todoID, nil) + getReq = withChiContext(getReq, map[string]string{"id": todoID}) + getRR := httptest.NewRecorder() + h.GetTodo(getRR, getReq) + + assert.Equal(t, http.StatusOK, getRR.Code) + var getResp map[string]interface{} + json.NewDecoder(getRR.Body).Decode(&getResp) + assert.True(t, getResp["success"].(bool)) + assert.Nil(t, getResp["meta"]) + getData := getResp["data"].(map[string]interface{}) + assert.Equal(t, todoID, getData["id"]) + assert.Equal(t, uniqueTitle, getData["title"]) + assert.Equal(t, "2% milk", getData["description"]) + assert.Equal(t, false, getData["completed"]) + }) +} + +func TestIntegration_CreateTodoAndListTodos(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + uniqueTitle1 := "ListFirst_" + uuid.New().String() + body1, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle1, Description: "First"}) + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body1)) + req1.Header.Set("Content-Type", "application/json") + rr1 := httptest.NewRecorder() + h.CreateTodo(rr1, req1) + assert.Equal(t, http.StatusCreated, rr1.Code) + var create1Resp map[string]interface{} + json.NewDecoder(rr1.Body).Decode(&create1Resp) + id1 := create1Resp["data"].(map[string]interface{})["id"].(string) + + uniqueTitle2 := "ListSecond_" + uuid.New().String() + body2, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle2, Description: "Second"}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body2)) + req2.Header.Set("Content-Type", "application/json") + rr2 := httptest.NewRecorder() + h.CreateTodo(rr2, req2) + assert.Equal(t, http.StatusCreated, rr2.Code) + var create2Resp map[string]interface{} + json.NewDecoder(rr2.Body).Decode(&create2Resp) + id2 := create2Resp["data"].(map[string]interface{})["id"].(string) + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/todos", nil) + listRR := httptest.NewRecorder() + h.ListTodos(listRR, listReq) + + assert.Equal(t, http.StatusOK, listRR.Code) + var listResp map[string]interface{} + json.NewDecoder(listRR.Body).Decode(&listResp) + assert.True(t, listResp["success"].(bool)) + assert.NotNil(t, listResp["meta"]) + + listData := listResp["data"].([]interface{}) + foundIDs := make([]string, len(listData)) + for i, item := range listData { + foundIDs[i] = item.(map[string]interface{})["id"].(string) + } + assert.Contains(t, foundIDs, id1) + assert.Contains(t, foundIDs, id2) + }) +} + +func TestIntegration_CreateTodoAndUpdateTodo(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + uniqueTitle := "OldTitle_" + uuid.New().String() + body, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle, Description: "Old Desc"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.CreateTodo(rr, req) + assert.Equal(t, http.StatusCreated, rr.Code) + var createResp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&createResp) + todoID := createResp["data"].(map[string]interface{})["id"].(string) + + updatedTitle := "UpdatedTitle_" + uuid.New().String() + updateBody, _ := json.Marshal(dto.UpdateTodoRequest{Title: updatedTitle, Description: "Updated Desc"}) + updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/todos/"+todoID, bytes.NewReader(updateBody)) + updateReq.Header.Set("Content-Type", "application/json") + updateReq = withChiContext(updateReq, map[string]string{"id": todoID}) + updateRR := httptest.NewRecorder() + h.UpdateTodo(updateRR, updateReq) + assert.Equal(t, http.StatusOK, updateRR.Code) + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/todos/"+todoID, nil) + getReq = withChiContext(getReq, map[string]string{"id": todoID}) + getRR := httptest.NewRecorder() + h.GetTodo(getRR, getReq) + assert.Equal(t, http.StatusOK, getRR.Code) + + var getResp map[string]interface{} + json.NewDecoder(getRR.Body).Decode(&getResp) + getData := getResp["data"].(map[string]interface{}) + assert.Equal(t, updatedTitle, getData["title"]) + assert.Equal(t, "Updated Desc", getData["description"]) + }) +} + +func TestIntegration_CreateTodoAndDeleteTodo(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + uniqueTitle := "DeleteMe_" + uuid.New().String() + body, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle, Description: "To be deleted"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.CreateTodo(rr, req) + assert.Equal(t, http.StatusCreated, rr.Code) + var createResp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&createResp) + todoID := createResp["data"].(map[string]interface{})["id"].(string) + + delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/todos/"+todoID, nil) + delReq = withChiContext(delReq, map[string]string{"id": todoID}) + delRR := httptest.NewRecorder() + h.DeleteTodo(delRR, delReq) + assert.Equal(t, http.StatusOK, delRR.Code) + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/todos/"+todoID, nil) + getReq = withChiContext(getReq, map[string]string{"id": todoID}) + getRR := httptest.NewRecorder() + h.GetTodo(getRR, getReq) + assert.Equal(t, http.StatusNotFound, getRR.Code) + + var getResp map[string]interface{} + json.NewDecoder(getRR.Body).Decode(&getResp) + assert.False(t, getResp["success"].(bool)) + assert.Nil(t, getResp["data"]) + assert.Equal(t, "NOT_FOUND", getResp["error"].(map[string]interface{})["code"]) + }) +} + +func TestIntegration_CreateTodoAndCompleteTodo(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + uniqueTitle := "CompleteMe_" + uuid.New().String() + body, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle, Description: ""}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.CreateTodo(rr, req) + assert.Equal(t, http.StatusCreated, rr.Code) + var createResp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&createResp) + todoID := createResp["data"].(map[string]interface{})["id"].(string) + assert.Equal(t, uniqueTitle, createResp["data"].(map[string]interface{})["title"]) + assert.Equal(t, false, createResp["data"].(map[string]interface{})["completed"]) + + completeReq := httptest.NewRequest(http.MethodPatch, "/api/v1/todos/"+todoID+"/complete", nil) + completeReq = withChiContext(completeReq, map[string]string{"id": todoID}) + completeRR := httptest.NewRecorder() + h.CompleteTodo(completeRR, completeReq) + assert.Equal(t, http.StatusOK, completeRR.Code) + + var completeResp map[string]interface{} + json.NewDecoder(completeRR.Body).Decode(&completeResp) + assert.True(t, completeResp["success"].(bool)) + assert.Equal(t, true, completeResp["data"].(map[string]interface{})["completed"]) + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/todos/"+todoID, nil) + getReq = withChiContext(getReq, map[string]string{"id": todoID}) + getRR := httptest.NewRecorder() + h.GetTodo(getRR, getReq) + assert.Equal(t, http.StatusOK, getRR.Code) + + var getResp map[string]interface{} + json.NewDecoder(getRR.Body).Decode(&getResp) + assert.Equal(t, true, getResp["data"].(map[string]interface{})["completed"]) + }) +} + +func TestIntegration_CreateTodoAndSearchTodos(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + uniqueTitle := "SearchableTodo_" + uuid.New().String() + body, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle, Description: "Searchable desc"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.CreateTodo(rr, req) + assert.Equal(t, http.StatusCreated, rr.Code) + var createResp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&createResp) + todoID := createResp["data"].(map[string]interface{})["id"].(string) + + searchReq := httptest.NewRequest(http.MethodGet, "/api/v1/todos/search?q="+uniqueTitle, nil) + searchRR := httptest.NewRecorder() + h.SearchTodos(searchRR, searchReq) + + assert.Equal(t, http.StatusOK, searchRR.Code) + var searchResp map[string]interface{} + json.NewDecoder(searchRR.Body).Decode(&searchResp) + assert.True(t, searchResp["success"].(bool)) + assert.NotNil(t, searchResp["meta"]) + + searchData := searchResp["data"].([]interface{}) + if assert.Equal(t, 1, len(searchData)) { + assert.Equal(t, todoID, searchData[0].(map[string]interface{})["id"].(string)) + } + }) +} + +func TestIntegration_CreateTodo_InvalidBody(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader([]byte("not valid json"))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.CreateTodo(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "VALIDATION_ERROR", resp["error"].(map[string]interface{})["code"]) + }) +} + +func TestIntegration_GetTodo_NotFound(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + nonExistentID := uuid.New().String() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/todos/"+nonExistentID, nil) + req = withChiContext(req, map[string]string{"id": nonExistentID}) + rr := httptest.NewRecorder() + h.GetTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + }) +} + +func TestIntegration_CompleteTodo_AlreadyDone(t *testing.T) { + testhelper.WithTx(t, db, func(tx *sql.Tx) { + h := integrationHandler(t) + + uniqueTitle := "AlreadyDone_" + uuid.New().String() + body, _ := json.Marshal(dto.CreateTodoRequest{Title: uniqueTitle, Description: ""}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.CreateTodo(rr, req) + assert.Equal(t, http.StatusCreated, rr.Code) + var createResp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&createResp) + todoID := createResp["data"].(map[string]interface{})["id"].(string) + + completeReq1 := httptest.NewRequest(http.MethodPatch, "/api/v1/todos/"+todoID+"/complete", nil) + completeReq1 = withChiContext(completeReq1, map[string]string{"id": todoID}) + completeRR1 := httptest.NewRecorder() + h.CompleteTodo(completeRR1, completeReq1) + assert.Equal(t, http.StatusOK, completeRR1.Code) + + completeReq2 := httptest.NewRequest(http.MethodPatch, "/api/v1/todos/"+todoID+"/complete", nil) + completeReq2 = withChiContext(completeReq2, map[string]string{"id": todoID}) + completeRR2 := httptest.NewRecorder() + h.CompleteTodo(completeRR2, completeReq2) + + assert.Equal(t, http.StatusConflict, completeRR2.Code) + var resp map[string]interface{} + json.NewDecoder(completeRR2.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "CONFLICT", resp["error"].(map[string]interface{})["code"]) + }) +} diff --git a/internal/todo/tests/todo_handler_test.go b/internal/todo/tests/todo_handler_test.go index 8577412..1f7f579 100644 --- a/internal/todo/tests/todo_handler_test.go +++ b/internal/todo/tests/todo_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -276,3 +277,372 @@ func TestSearchTodos_MissingQuery(t *testing.T) { assert.Nil(t, resp["data"]) assert.Equal(t, "VALIDATION_ERROR", resp["error"].(map[string]interface{})["code"]) } + +func TestGetTodo_InvalidID(t *testing.T) { + h, _ := setupHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/todos/not-a-uuid", nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": "not-a-uuid"}) + + h.GetTodo(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) +} + +func TestListTodos_Success(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + todos := []*entity.Todo{newTestTodo(id, "Test")} + repo.On("GetAll", mock.Anything, (*string)(nil), 20).Return(todos, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/todos", nil) + rr := httptest.NewRecorder() + + h.ListTodos(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) + assert.NotNil(t, resp["meta"]) + repo.AssertExpectations(t) +} + +func TestListTodos_WithCursor(t *testing.T) { + h, repo := setupHandler(t) + + cursor := "next-cursor" + todos := []*entity.Todo{} + repo.On("GetAll", mock.Anything, &cursor, 10).Return(todos, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/todos?cursor=next-cursor&limit=10", nil) + rr := httptest.NewRecorder() + + h.ListTodos(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + repo.AssertExpectations(t) +} + +func TestListTodos_RepoError(t *testing.T) { + h, repo := setupHandler(t) + + repo.On("GetAll", mock.Anything, (*string)(nil), 20).Return([]*entity.Todo{}, service.ErrTodoNotFound) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/todos", nil) + rr := httptest.NewRecorder() + + h.ListTodos(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + repo.AssertExpectations(t) +} + +func TestUpdateTodo_Success(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + todo := newTestTodoWithDesc(id, "Updated Title", "Updated Desc") + repo.On("GetByID", mock.Anything, id).Return(todo, nil) + repo.On("Update", mock.Anything, mock.Anything).Return(nil) + + body, _ := json.Marshal(dto.UpdateTodoRequest{Title: "Updated Title", Description: "Updated Desc"}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/todos/"+id.String(), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.UpdateTodo(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) + assert.Nil(t, resp["meta"]) + repo.AssertExpectations(t) +} + +func TestUpdateTodo_InvalidID(t *testing.T) { + h, _ := setupHandler(t) + + body, _ := json.Marshal(dto.UpdateTodoRequest{Title: "Updated Title"}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/todos/not-a-uuid", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": "not-a-uuid"}) + + h.UpdateTodo(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) +} + +func TestUpdateTodo_BadJSON(t *testing.T) { + h, _ := setupHandler(t) + + id := uuid.New() + req := httptest.NewRequest(http.MethodPut, "/api/v1/todos/"+id.String(), bytes.NewReader([]byte("{not json"))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.UpdateTodo(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) +} + +func TestUpdateTodo_NotFound(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + repo.On("GetByID", mock.Anything, id).Return(nil, service.ErrTodoNotFound) + + body, _ := json.Marshal(dto.UpdateTodoRequest{Title: "Updated Title"}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/todos/"+id.String(), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.UpdateTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + repo.AssertExpectations(t) +} + +func TestDeleteTodo_InvalidID(t *testing.T) { + h, _ := setupHandler(t) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/todos/not-a-uuid", nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": "not-a-uuid"}) + + h.DeleteTodo(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) +} + +func TestDeleteTodo_NotFound(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + repo.On("GetByID", mock.Anything, id).Return(nil, service.ErrTodoNotFound) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/todos/"+id.String(), nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.DeleteTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + repo.AssertExpectations(t) +} + +func TestCompleteTodo_NotFound(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + repo.On("GetByID", mock.Anything, id).Return(nil, service.ErrTodoNotFound) + + req := httptest.NewRequest(http.MethodPatch, "/api/v1/todos/"+id.String()+"/complete", nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.CompleteTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + repo.AssertExpectations(t) +} + +func TestCompleteTodo_InvalidID(t *testing.T) { + h, _ := setupHandler(t) + + req := httptest.NewRequest(http.MethodPatch, "/api/v1/todos/not-a-uuid/complete", nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": "not-a-uuid"}) + + h.CompleteTodo(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) +} + +func TestCreateTodo_BadJSON(t *testing.T) { + h, _ := setupHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader([]byte("{invalid"))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + h.CreateTodo(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) +} + +func TestCreateTodo_RepoError(t *testing.T) { + h, repo := setupHandler(t) + + body, _ := json.Marshal(dto.CreateTodoRequest{Title: "Buy milk", Description: "2%"}) + repo.On("Create", mock.Anything, mock.Anything).Return(errors.New("db error")) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/todos", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + h.CreateTodo(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + repo.AssertExpectations(t) +} + +func TestGetTodo_RepoError(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + repo.On("GetByID", mock.Anything, id).Return(nil, errors.New("db error")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/todos/"+id.String(), nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.GetTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + repo.AssertExpectations(t) +} + +func TestUpdateTodo_RepoError(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + repo.On("GetByID", mock.Anything, id).Return(nil, errors.New("db error")) + + body, _ := json.Marshal(dto.UpdateTodoRequest{Title: "Updated Title"}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/todos/"+id.String(), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.UpdateTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + repo.AssertExpectations(t) +} + +func TestDeleteTodo_RepoError(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + repo.On("GetByID", mock.Anything, id).Return(nil, errors.New("db error")) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/todos/"+id.String(), nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.DeleteTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + repo.AssertExpectations(t) +} + +func TestCompleteTodo_RepoError(t *testing.T) { + h, repo := setupHandler(t) + + id := uuid.New() + repo.On("GetByID", mock.Anything, id).Return(nil, errors.New("db error")) + + req := httptest.NewRequest(http.MethodPatch, "/api/v1/todos/"+id.String()+"/complete", nil) + rr := httptest.NewRecorder() + req = withChiContext(req, map[string]string{"id": id.String()}) + + h.CompleteTodo(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) + repo.AssertExpectations(t) +} + +func TestSearchTodos_RepoError(t *testing.T) { + h, repo := setupHandler(t) + + repo.On("Search", mock.Anything, "test", (*string)(nil), 20).Return([]*entity.Todo{}, errors.New("db error")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/todos/search?q=test", nil) + rr := httptest.NewRecorder() + + h.SearchTodos(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + repo.AssertExpectations(t) +} diff --git a/internal/todo/tests/todo_integration_test.go b/internal/todo/tests/todo_integration_test.go new file mode 100644 index 0000000..3ef3494 --- /dev/null +++ b/internal/todo/tests/todo_integration_test.go @@ -0,0 +1,138 @@ +package tests + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" + + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" + "github.com/IDTS-LAB/go-codebase/internal/testhelper" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" + todoPersistence "github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/persistence" + "github.com/google/uuid" +) + +var db *sql.DB + +func TestMain(m *testing.M) { + var cleanup func() + db, cleanup = testhelper.SetupTestDB(m) + code := m.Run() + cleanup() + os.Exit(code) +} + +func TestTodoRepository_CreateAndGetByID(t *testing.T) { + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + + todo := entity.NewTodo("Buy milk", "2% milk") + err := repo.Create(context.Background(), todo) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := repo.GetByID(context.Background(), todo.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Title != todo.Title { + t.Errorf("expected title %s, got %s", todo.Title, got.Title) + } + if got.Description != todo.Description { + t.Errorf("expected description %s, got %s", todo.Description, got.Description) + } +} + +func TestTodoRepository_Update(t *testing.T) { + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + + todo := entity.NewTodo("Old title", "Old desc") + repo.Create(context.Background(), todo) + + todo.Update("New title", "New desc") + err := repo.Update(context.Background(), todo) + if err != nil { + t.Fatalf("Update: %v", err) + } + + got, _ := repo.GetByID(context.Background(), todo.ID) + if got.Title != "New title" { + t.Errorf("expected title 'New title', got %s", got.Title) + } +} + +func TestTodoRepository_Delete(t *testing.T) { + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + + todo := entity.NewTodo("Delete me", "To be deleted") + repo.Create(context.Background(), todo) + + err := repo.Delete(context.Background(), todo.ID) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = repo.GetByID(context.Background(), todo.ID) + if err == nil { + t.Error("expected error after delete") + } +} + +func TestTodoRepository_GetAll(t *testing.T) { + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + + repo.Create(context.Background(), entity.NewTodo("First", "First desc")) + repo.Create(context.Background(), entity.NewTodo("Second", "Second desc")) + + todos, _, _, _, _, err := repo.GetAll(context.Background(), nil, 20) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(todos) < 2 { + t.Errorf("expected at least 2 todos, got %d", len(todos)) + } +} + +func TestTodoRepository_Search(t *testing.T) { + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + + uniqueTitle := "SearchTest_" + uuid.New().String() + repo.Create(context.Background(), entity.NewTodo(uniqueTitle, "Description")) + + results, _, _, _, _, err := repo.Search(context.Background(), uniqueTitle, nil, 20) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } +} + +func TestTodoRepository_GetByID_NotFound(t *testing.T) { + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + _, err := repo.GetByID(context.Background(), uuid.New()) + if err == nil { + t.Error("expected error for nonexistent todo") + } +} + +func TestTodoRepository_Complete(t *testing.T) { + repo := todoPersistence.NewTodoRepository(db, &tenantfilter.Config{}) + + todo := entity.NewTodo("Complete me", "") + repo.Create(context.Background(), todo) + + todo.Complete() + err := repo.Update(context.Background(), todo) + if err != nil { + t.Fatalf("Update after complete: %v", err) + } + + got, _ := repo.GetByID(context.Background(), todo.ID) + if !got.Completed { + t.Error("expected todo to be completed") + } +} diff --git a/internal/user/application/command/create_user.go b/internal/user/application/command/create_user.go new file mode 100644 index 0000000..cc3d077 --- /dev/null +++ b/internal/user/application/command/create_user.go @@ -0,0 +1,40 @@ +package command + +import ( + "context" + "time" + + authEntity "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" + "github.com/google/uuid" +) + +type CreateUserCommand struct { + Email string + Name string + IsActive bool +} + +type CreateUserHandler struct { + repo repository.UserRepository +} + +func NewCreateUserHandler(repo repository.UserRepository) *CreateUserHandler { + return &CreateUserHandler{repo: repo} +} + +func (h *CreateUserHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(CreateUserCommand) + now := time.Now().UTC() + user := &authEntity.User{ + Entity: domain.Entity{ID: uuid.New(), CreatedAt: now, UpdatedAt: now}, + Email: c.Email, + Name: c.Name, + IsActive: c.IsActive, + } + if err := h.repo.Create(ctx, user); err != nil { + return nil, err + } + return user, nil +} diff --git a/internal/user/domain/repository/user_repository.go b/internal/user/domain/repository/user_repository.go index a1bb33c..68f49bd 100644 --- a/internal/user/domain/repository/user_repository.go +++ b/internal/user/domain/repository/user_repository.go @@ -8,6 +8,7 @@ import ( ) type UserRepository interface { + Create(ctx context.Context, user *entity.User) error List(ctx context.Context, cursor *string, limit int) ([]*entity.User, *string, *string, bool, bool, error) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) Update(ctx context.Context, user *entity.User) error diff --git a/internal/user/infrastructure/persistence/user_repository.go b/internal/user/infrastructure/persistence/user_repository.go index 59f0bb6..453a697 100644 --- a/internal/user/infrastructure/persistence/user_repository.go +++ b/internal/user/infrastructure/persistence/user_repository.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + "github.com/lib/pq" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/cursor" @@ -102,7 +104,7 @@ func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Use q := sqlc.New(r.db) row, err := q.GetUserByID(ctx, id) if err == sql.ErrNoRows { - return nil, fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found: %w", domain.ErrNotFound) } if err != nil { return nil, fmt.Errorf("get user: %w", err) @@ -119,6 +121,20 @@ func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Use return u, nil } +func (r *userRepository) Create(ctx context.Context, user *entity.User) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO users (id, email, name, is_active, email_verified_at, created_at, updated_at, password) + VALUES ($1, $2, $3, $4, $5, $6, $7, ''::varchar) + `, user.ID, user.Email, user.Name, user.IsActive, nil, user.CreatedAt, user.UpdatedAt) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { + return fmt.Errorf("create user: %w", domain.ErrAlreadyExists) + } + return fmt.Errorf("create user: %w", err) + } + return nil +} + func (r *userRepository) Update(ctx context.Context, user *entity.User) error { q := sqlc.New(r.db) rows, err := q.UpdateUser(ctx, sqlc.UpdateUserParams{ @@ -132,7 +148,7 @@ func (r *userRepository) Update(ctx context.Context, user *entity.User) error { return fmt.Errorf("update user: %w", err) } if rows == 0 { - return fmt.Errorf("user not found") + return fmt.Errorf("user not found: %w", domain.ErrNotFound) } return nil } @@ -144,7 +160,7 @@ func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error { return fmt.Errorf("delete user: %w", err) } if rows == 0 { - return fmt.Errorf("user not found") + return fmt.Errorf("user not found: %w", domain.ErrNotFound) } return nil } diff --git a/internal/user/interfaces/http/handler.go b/internal/user/interfaces/http/handler.go index 881fffd..98a892f 100644 --- a/internal/user/interfaces/http/handler.go +++ b/internal/user/interfaces/http/handler.go @@ -36,6 +36,12 @@ type UserResponse struct { UpdatedAt string `json:"updated_at"` } +type CreateUserRequest struct { + Email string `json:"email" validate:"required,email"` + Name string `json:"name" validate:"required"` + IsActive bool `json:"is_active"` +} + type UpdateUserRequest struct { Name string `json:"name"` Email string `json:"email"` @@ -98,6 +104,38 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { utils.RespondCursorPaginated(w, usersResp, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, result.Limit) } +// Create godoc +// @Summary Create a user +// @Description Create a new user +// @Tags users +// @Accept json +// @Produce json +// @Param request body CreateUserRequest true "User to create" +// @Success 201 {object} utils.APIResponse{data=UserResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 409 {object} utils.APIResponse +// @Security BearerAuth +// @Router /users [post] +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var req CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + + resp, err := h.commandBus.Dispatch(r.Context(), command.CreateUserCommand{ + Email: req.Email, + Name: req.Name, + IsActive: req.IsActive, + }) + if err != nil { + utils.Handle(w, r, nil, err) + return + } + + utils.HandleCreated(w, r, userToResponse(resp.(*authEntity.User)), nil) +} + // Get godoc // @Summary Get user by ID // @Description Get a user by their ID diff --git a/internal/user/interfaces/http/handler_test.go b/internal/user/interfaces/http/handler_test.go new file mode 100644 index 0000000..4fe1eb2 --- /dev/null +++ b/internal/user/interfaces/http/handler_test.go @@ -0,0 +1,532 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + authEntity "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + "github.com/IDTS-LAB/go-codebase/internal/user/application/command" + "github.com/IDTS-LAB/go-codebase/internal/user/application/query" + "github.com/IDTS-LAB/go-codebase/internal/user/public" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +type mockHandler struct { + result any + err error +} + +func (h *mockHandler) Handle(ctx context.Context, _ any) (any, error) { + return h.result, h.err +} + +type mockProfileProvider struct { + profile *public.UserProfile + err error +} + +func (m *mockProfileProvider) GetProfile(ctx context.Context, userID uuid.UUID) (*public.UserProfile, error) { + return m.profile, m.err +} + +func withChiParams(r *http.Request, params map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func withUserID(r *http.Request, userID uuid.UUID) *http.Request { + return r.WithContext(context.WithValue(r.Context(), middleware.UserIDKey, userID.String())) +} + +func TestCreate_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + userID := uuid.New() + cmdBus.Register(command.CreateUserCommand{}, &mockHandler{ + result: &authEntity.User{ + Entity: domain.Entity{ID: userID}, + Email: "new@example.com", + Name: "New User", + }, + }) + + body, _ := json.Marshal(CreateUserRequest{Email: "new@example.com", Name: "New User", IsActive: true}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusCreated, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) +} + +func TestCreate_BadJSON(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader([]byte("{invalid"))) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) +} + +func TestCreate(t *testing.T) { + t.Run("validation error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + cmdBus.Register(command.CreateUserCommand{}, &mockHandler{err: domain.ErrValidation}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader([]byte("{}"))) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + }) + + t.Run("bus error status", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + cmdBus.Register(command.CreateUserCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(CreateUserRequest{Email: "fail@example.com", Name: "Fail"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + }) +} + +func TestCreate_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + cmdBus.Register(command.CreateUserCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(CreateUserRequest{Email: "fail@example.com", Name: "Fail"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.Create(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestList_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + qBus.Register(query.ListUsersQuery{}, &mockHandler{ + result: query.ListUsersResult{ + Users: []*authEntity.User{}, + Limit: 20, + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users", nil) + + h.List(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) +} + +func TestList_WithCursor(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + qBus.Register(query.ListUsersQuery{}, &mockHandler{ + result: query.ListUsersResult{ + Users: []*authEntity.User{}, + HasNext: true, + Limit: 10, + }, + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users?cursor=abc&limit=10", nil) + + h.List(w, r) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestList_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + qBus.Register(query.ListUsersQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users", nil) + + h.List(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestGet_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + qBus.Register(query.GetUserQuery{}, &mockHandler{ + result: &authEntity.User{ + Entity: domain.Entity{ID: id}, + Email: "test@example.com", + Name: "Test User", + }, + }) + + utils.IsProduction = true + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Get(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) +} + +func TestGet_InvalidUUID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/invalid", nil) + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.Get(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGet_NotFound(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + qBus.Register(query.GetUserQuery{}, &mockHandler{ + err: domain.ErrNotFound, + }) + + utils.IsProduction = true + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Get(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestGet_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + qBus.Register(query.GetUserQuery{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Get(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestMe_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + userID := uuid.New() + profileProv := &mockProfileProvider{ + profile: &public.UserProfile{ + ID: userID.String(), + Email: "test@example.com", + Name: "Test User", + Roles: []string{"admin"}, + IsActive: true, + CreatedAt: "2024-01-01T00:00:00Z", + UpdatedAt: "2024-01-01T00:00:00Z", + }, + } + h := &Handler{commandBus: cmdBus, queryBus: qBus, profileProv: profileProv} + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/me", nil) + r = withUserID(r, userID) + + h.Me(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) +} + +func TestMe_Unauthorized(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/me", nil) + + h.Me(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestMe_InvalidUUID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/me", nil) + r = r.WithContext(context.WithValue(r.Context(), middleware.UserIDKey, "not-a-uuid")) + + h.Me(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestMe_ProfileError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + userID := uuid.New() + profileProv := &mockProfileProvider{err: errors.New("profile error")} + h := &Handler{commandBus: cmdBus, queryBus: qBus, profileProv: profileProv} + + utils.IsProduction = true + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/me", nil) + r = withUserID(r, userID) + + h.Me(w, r) + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestUpdate_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + cmdBus.Register(command.UpdateUserCommand{}, &mockHandler{ + result: &authEntity.User{ + Entity: domain.Entity{ID: id}, + Email: "updated@example.com", + Name: "Updated User", + }, + }) + + body, _ := json.Marshal(UpdateUserRequest{Name: "Updated User", Email: "updated@example.com"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/users/"+id.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Update(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) +} + +func TestUpdate_InvalidUUID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + body, _ := json.Marshal(UpdateUserRequest{Name: "Test"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/users/invalid", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.Update(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdate_BadJSON(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/users/"+id.String(), bytes.NewReader([]byte("{invalid"))) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Update(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdate_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + cmdBus.Register(command.UpdateUserCommand{}, &mockHandler{err: errors.New("db error")}) + + body, _ := json.Marshal(UpdateUserRequest{Name: "Test"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/users/"+id.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Update(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} + +func TestDelete_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + cmdBus.Register(command.DeleteUserCommand{}, &mockHandler{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDelete_InvalidUUID(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/invalid", nil) + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDelete_NotFound(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + cmdBus.Register(command.DeleteUserCommand{}, &mockHandler{err: domain.ErrNotFound}) + + utils.IsProduction = true + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestDelete_BusError(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus) + + id := uuid.New() + cmdBus.Register(command.DeleteUserCommand{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/"+id.String(), nil) + r = withChiParams(r, map[string]string{"id": id.String()}) + + h.Delete(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) +} diff --git a/internal/user/interfaces/http/routes.go b/internal/user/interfaces/http/routes.go index 7c396a3..0ee8193 100644 --- a/internal/user/interfaces/http/routes.go +++ b/internal/user/interfaces/http/routes.go @@ -12,6 +12,10 @@ func NewRouter(handler *Handler, authMiddleware func(http.Handler) http.Handler, r.Group(func(r chi.Router) { r.Use(authMiddleware) + r.Group(func(r chi.Router) { + r.Use(middleware.Authorization(authorizer, "user", "create")) + r.Post("/", handler.Create) + }) r.Group(func(r chi.Router) { r.Use(middleware.Authorization(authorizer, "user", "read")) r.Get("/me", handler.Me) diff --git a/internal/user/module.go b/internal/user/module.go index d6de152..a332062 100644 --- a/internal/user/module.go +++ b/internal/user/module.go @@ -29,6 +29,7 @@ func registerHandlers( repo repository.UserRepository, roleProvider roleProvider.AuthorizationProvider, ) { + commandBus.Register(command.CreateUserCommand{}, command.NewCreateUserHandler(repo)) commandBus.Register(command.UpdateUserCommand{}, command.NewUpdateUserHandler(repo)) commandBus.Register(command.DeleteUserCommand{}, command.NewDeleteUserHandler(repo)) diff --git a/internal/user/tests/user_handler_integration_test.go b/internal/user/tests/user_handler_integration_test.go new file mode 100644 index 0000000..c99692d --- /dev/null +++ b/internal/user/tests/user_handler_integration_test.go @@ -0,0 +1,343 @@ +package tests + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" + "github.com/IDTS-LAB/go-codebase/internal/user/application/command" + "github.com/IDTS-LAB/go-codebase/internal/user/application/query" + userRepo "github.com/IDTS-LAB/go-codebase/internal/user/infrastructure/persistence" + userHttp "github.com/IDTS-LAB/go-codebase/internal/user/interfaces/http" +) + +type mockRoleProvider struct{} + +func (m *mockRoleProvider) GetUserRoles(_ context.Context, _ uuid.UUID) ([]string, error) { + return nil, nil +} + +func setupUserHandler(t *testing.T) (*userHttp.Handler, *sql.DB) { + t.Helper() + repo := userRepo.NewUserRepository(db, &tenantfilter.Config{}) + + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + + cmdBus.Register(command.CreateUserCommand{}, command.NewCreateUserHandler(repo)) + cmdBus.Register(command.UpdateUserCommand{}, command.NewUpdateUserHandler(repo)) + cmdBus.Register(command.DeleteUserCommand{}, command.NewDeleteUserHandler(repo)) + qBus.Register(query.GetUserQuery{}, query.NewGetUserHandler(repo, &mockRoleProvider{})) + qBus.Register(query.ListUsersQuery{}, query.NewListUsersHandler(repo)) + + h := userHttp.NewHandler(cmdBus, qBus) + return h, db +} + +func setupRouter(h *userHttp.Handler) *chi.Mux { + r := chi.NewRouter() + r.Post("/users", h.Create) + r.Get("/users", h.List) + r.Get("/users/{id}", h.Get) + r.Put("/users/{id}", h.Update) + r.Delete("/users/{id}", h.Delete) + return r +} + +type apiResponse struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Meta json.RawMessage `json:"meta"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +type userResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func TestUserHandler_CreateAndGet(t *testing.T) { + h, _ := setupUserHandler(t) + router := setupRouter(h) + + email := "handler-create-get-" + uuid.New().String() + "@test.com" + body := map[string]interface{}{ + "email": email, + "name": "Create Get Test", + "is_active": true, + } + bodyBytes, _ := json.Marshal(body) + + req := httptest.NewRequest("POST", "/users", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var createResp apiResponse + err := json.Unmarshal(w.Body.Bytes(), &createResp) + assert.NoError(t, err) + assert.True(t, createResp.Success) + + var createdUser userResponse + err = json.Unmarshal(createResp.Data, &createdUser) + assert.NoError(t, err) + assert.Equal(t, "Create Get Test", createdUser.Name) + assert.True(t, createdUser.IsActive) + + req = httptest.NewRequest("GET", "/users/"+createdUser.ID, nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var getResp apiResponse + err = json.Unmarshal(w.Body.Bytes(), &getResp) + assert.NoError(t, err) + assert.True(t, getResp.Success) + + var getUser userResponse + err = json.Unmarshal(getResp.Data, &getUser) + assert.NoError(t, err) + assert.Equal(t, createdUser.ID, getUser.ID) + assert.Equal(t, email, getUser.Email) + assert.Equal(t, "Create Get Test", getUser.Name) + assert.True(t, getUser.IsActive) +} + +func TestUserHandler_List(t *testing.T) { + h, _ := setupUserHandler(t) + router := setupRouter(h) + + email1 := "handler-list-1-" + uuid.New().String() + "@test.com" + body1 := map[string]interface{}{ + "email": email1, + "name": "List One", + "is_active": true, + } + bodyBytes1, _ := json.Marshal(body1) + req := httptest.NewRequest("POST", "/users", bytes.NewReader(bodyBytes1)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + email2 := "handler-list-2-" + uuid.New().String() + "@test.com" + body2 := map[string]interface{}{ + "email": email2, + "name": "List Two", + "is_active": true, + } + bodyBytes2, _ := json.Marshal(body2) + req = httptest.NewRequest("POST", "/users", bytes.NewReader(bodyBytes2)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + req = httptest.NewRequest("GET", "/users", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var listResp apiResponse + err := json.Unmarshal(w.Body.Bytes(), &listResp) + assert.NoError(t, err) + assert.True(t, listResp.Success) + + var users []userResponse + err = json.Unmarshal(listResp.Data, &users) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(users), 2) + + emails := make(map[string]bool) + for _, u := range users { + emails[u.Email] = true + } + assert.True(t, emails[email1]) + assert.True(t, emails[email2]) +} + +func TestUserHandler_Update(t *testing.T) { + h, _ := setupUserHandler(t) + router := setupRouter(h) + + updateEmail := "handler-update-" + uuid.New().String() + "@test.com" + createBody := map[string]interface{}{ + "email": updateEmail, + "name": "Before Update", + "is_active": true, + } + bodyBytes, _ := json.Marshal(createBody) + req := httptest.NewRequest("POST", "/users", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + var createResp apiResponse + json.Unmarshal(w.Body.Bytes(), &createResp) + var createdUser userResponse + json.Unmarshal(createResp.Data, &createdUser) + + updatedEmail := "handler-updated-" + uuid.New().String() + "@test.com" + updateBody := map[string]interface{}{ + "email": updatedEmail, + "name": "After Update", + "is_active": true, + } + updateBytes, _ := json.Marshal(updateBody) + req = httptest.NewRequest("PUT", "/users/"+createdUser.ID, bytes.NewReader(updateBytes)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var updateResp apiResponse + err := json.Unmarshal(w.Body.Bytes(), &updateResp) + assert.NoError(t, err) + assert.True(t, updateResp.Success) + + var updatedUser userResponse + err = json.Unmarshal(updateResp.Data, &updatedUser) + assert.NoError(t, err) + assert.Equal(t, updatedEmail, updatedUser.Email) + assert.Equal(t, "After Update", updatedUser.Name) + + req = httptest.NewRequest("GET", "/users/"+createdUser.ID, nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var getResp apiResponse + json.Unmarshal(w.Body.Bytes(), &getResp) + var fetchedUser userResponse + json.Unmarshal(getResp.Data, &fetchedUser) + assert.Equal(t, updatedEmail, fetchedUser.Email) + assert.Equal(t, "After Update", fetchedUser.Name) +} + +func TestUserHandler_Delete(t *testing.T) { + h, _ := setupUserHandler(t) + router := setupRouter(h) + + deleteEmail := "handler-delete-" + uuid.New().String() + "@test.com" + createBody := map[string]interface{}{ + "email": deleteEmail, + "name": "Delete Test", + "is_active": true, + } + bodyBytes, _ := json.Marshal(createBody) + req := httptest.NewRequest("POST", "/users", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + var createResp apiResponse + json.Unmarshal(w.Body.Bytes(), &createResp) + var createdUser userResponse + json.Unmarshal(createResp.Data, &createdUser) + + req = httptest.NewRequest("DELETE", "/users/"+createdUser.ID, nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var deleteResp apiResponse + err := json.Unmarshal(w.Body.Bytes(), &deleteResp) + assert.NoError(t, err) + assert.True(t, deleteResp.Success) + + req = httptest.NewRequest("GET", "/users/"+createdUser.ID, nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var getResp apiResponse + err = json.Unmarshal(w.Body.Bytes(), &getResp) + assert.NoError(t, err) + assert.True(t, getResp.Success) + + var fetchedUser userResponse + err = json.Unmarshal(getResp.Data, &fetchedUser) + assert.NoError(t, err) + assert.Equal(t, createdUser.ID, fetchedUser.ID) + assert.Equal(t, deleteEmail, fetchedUser.Email) + assert.Equal(t, "Delete Test", fetchedUser.Name) +} + +func TestUserHandler_Create_Conflict(t *testing.T) { + h, _ := setupUserHandler(t) + router := setupRouter(h) + + conflictEmail := "handler-conflict-" + uuid.New().String() + "@test.com" + body := map[string]interface{}{ + "email": conflictEmail, + "name": "Conflict Test", + "is_active": true, + } + bodyBytes, _ := json.Marshal(body) + + req := httptest.NewRequest("POST", "/users", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + req = httptest.NewRequest("POST", "/users", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) + + var resp apiResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "CONFLICT", resp.Error.Code) +} + +func TestUserHandler_Get_NotFound(t *testing.T) { + h, _ := setupUserHandler(t) + router := setupRouter(h) + + req := httptest.NewRequest("GET", "/users/"+uuid.New().String(), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + + var resp apiResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "NOT_FOUND", resp.Error.Code) +} diff --git a/internal/user/tests/user_integration_test.go b/internal/user/tests/user_integration_test.go new file mode 100644 index 0000000..06caf92 --- /dev/null +++ b/internal/user/tests/user_integration_test.go @@ -0,0 +1,120 @@ +package tests + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" + + authEntity "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" + "github.com/IDTS-LAB/go-codebase/internal/testhelper" + userPersistence "github.com/IDTS-LAB/go-codebase/internal/user/infrastructure/persistence" + "github.com/google/uuid" +) + +var db *sql.DB + +func TestMain(m *testing.M) { + var cleanup func() + db, cleanup = testhelper.SetupTestDB(m) + code := m.Run() + cleanup() + os.Exit(code) +} + +func newUser(email, name string) *authEntity.User { + return &authEntity.User{ + Entity: domain.NewEntity(), + Email: email, + Password: "$2a$10$test_hash", + Name: name, + IsActive: true, + } +} + +func TestUserRepository_CreateAndGetByID(t *testing.T) { + repo := userPersistence.NewUserRepository(db, &tenantfilter.Config{}) + + email := "create-get-user-" + uuid.New().String()[:8] + "@example.com" + user := newUser(email, "Create Test") + err := repo.Create(context.Background(), user) + if err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := repo.GetByID(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Email != user.Email { + t.Errorf("expected email %s, got %s", user.Email, got.Email) + } + if got.Name != user.Name { + t.Errorf("expected name %s, got %s", user.Name, got.Name) + } +} + +func TestUserRepository_List(t *testing.T) { + repo := userPersistence.NewUserRepository(db, &tenantfilter.Config{}) + + repo.Create(context.Background(), newUser("list1-user-"+uuid.New().String()[:8]+"@example.com", "List One")) + repo.Create(context.Background(), newUser("list2-user-"+uuid.New().String()[:8]+"@example.com", "List Two")) + + users, _, _, _, _, err := repo.List(context.Background(), nil, 20) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(users) < 2 { + t.Errorf("expected at least 2 users, got %d", len(users)) + } +} + +func TestUserRepository_Update(t *testing.T) { + repo := userPersistence.NewUserRepository(db, &tenantfilter.Config{}) + + email := "update-user-test-" + uuid.New().String()[:8] + "@example.com" + user := newUser(email, "Before") + repo.Create(context.Background(), user) + + user.Name = "After" + user.Email = "after-update-" + uuid.New().String()[:8] + "@example.com" + err := repo.Update(context.Background(), user) + if err != nil { + t.Fatalf("Update: %v", err) + } + + got, _ := repo.GetByID(context.Background(), user.ID) + if got.Name != "After" { + t.Errorf("expected name 'After', got %s", got.Name) + } +} + +func TestUserRepository_Delete(t *testing.T) { + repo := userPersistence.NewUserRepository(db, &tenantfilter.Config{}) + + email := "delete-user-test-" + uuid.New().String()[:8] + "@example.com" + user := newUser(email, "Delete Test") + repo.Create(context.Background(), user) + + err := repo.Delete(context.Background(), user.ID) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = repo.GetByID(context.Background(), user.ID) + if err == nil { + t.Error("expected error after delete") + } +} + +func TestUserRepository_GetByID_NotFound(t *testing.T) { + repo := userPersistence.NewUserRepository(db, &tenantfilter.Config{}) + _, err := repo.GetByID(context.Background(), uuid.New()) + if err == nil { + t.Error("expected error for nonexistent user") + } +}