diff --git a/Makefile b/Makefile index 8c62e43..92c5eca 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ GO_ARCH := GOARCH=amd64 all: sqlc fmt lint test integrationtest gosec build .PHONY: all-tools -all-tools: lint-install sqlc-install gosec-install goimports-install +all-tools: lint-install sqlc-install gosec-install goimports-install mockgen-install .PHONY: fmt fmt: @@ -29,31 +29,14 @@ run: build ./bin/bank .PHONY: test -test: fmt +test: fmt mock $(GO_ARCH) $(CGO_FLAGS) \ go test -race -v -cover -short ./... -######################### -# Docker wormhole pattern -# Testcontainers will automatically detect if it's inside a container and instead of "localhost" will use the default gateway's IP. -# -# However, additional configuration is required if you use volume mapping. The following points need to be considered: -# - The docker socket must be available via a volume mount -# - The 'local' source code directory must be volume mounted at the same path inside the container that Testcontainers runs in, -# so that Testcontainers is able to set up the correct volume mounts for the containers it spawns. -# -# https://docs.docker.com/desktop/extensions-sdk/guides/use-docker-socket-from-backend/ -# -# docker run -it --rm -v $PWD:$PWD -w $PWD -v /var/run/docker.sock:/var/run/docker.sock bank-integration-test -# docker run -it --rm -v $PWD:$PWD -w $PWD -v /var/run/docker.sock.raw:/var/run/docker.sock bank-integration-test -######################### -.PHONY: integrationtest-docker-run -integrationtest-docker-run: integrationtest-docker-build - docker run -it --rm --name bank-integration-test \ - -v $(PWD):$(PWD) \ - -w $(PWD) \ - -v /var/run/docker.sock.raw:/var/run/docker.sock \ - bank-integration-test +# Server requires a running PostgreSQL `make -C build up` +.PHONY: server +server: + go run cmd/bank/main.go .PHONY: integrationtest integrationtest: fmt @@ -68,6 +51,15 @@ lint-install: lint: fmt revive -config=revivie-lint.toml ./... +.PHONY: mockgen-install +mockgen-install: + go install go.uber.org/mock/mockgen@latest + +# Generate mocks +.PHONY: mock +mock: mockgen-install + mockgen -source internal/app/bank/bank.go -destination internal/app/bank/mock/bank.go -package mockdb + # We use https://docs.sqlc.dev/en/stable/index.html for database queries and mapping. This library # has support for PostgreSQL, MySQL and SQLite, no other DBs supported. .PHONY: sqlc-install @@ -106,13 +98,3 @@ docker-build: -t "bank" \ . -# Build a container to be used purely for running integration tests, docker in docker with testcontainers. -# Start tests by 'make integrationtest-docker-run' -.PHONY: integrationtest-docker-build -integrationtest-docker-build: - DOCKER_DEFAULT_PLATFORM=linux/amd64 \ - DOCKER_BUILDKIT=1 \ - docker build \ - -f build/docker/integrationTest.Dockerfile \ - -t "bank-integration-test" \ - . diff --git a/README.md b/README.md index 3291ce7..4eaec93 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Learn everything about backend web development: Golang, Postgres, Redis, Gin, gR * testcontainers-go, https://github.com/testcontainers/testcontainers-go * sqlc, https://sqlc.dev * PostgreSQL, https://www.postgresql.org +* Gin web framework, https://github.com/gin-gonic/gin ## VSCode setup diff --git a/build/README.md b/build/README.md index 11892fe..1df3b0c 100644 --- a/build/README.md +++ b/build/README.md @@ -63,4 +63,24 @@ Create next migration path $ make migrate-db-create-next-migration-path /migrations/20230907110931_bank-db-migration.up.sql /migrations/20230907110931_bank-db-migration.down.sql +~~~ + +## Test + +Example uses [httpie](https://httpie.io) + +~~~json +$ http POST localhost:8080/users username=johndoe password=qwerty full_name=John email=john.doe@testbank.qwerty +HTTP/1.1 200 OK +Content-Length: 164 +Content-Type: application/json; charset=utf-8 +Date: Fri, 27 Oct 2023 18:41:58 GMT + +{ + "created_at": "2023-10-27T18:41:58.248839Z", + "email": "john.doe@testbank.qwerty", + "full_name": "John", + "password_changed_at": "0001-01-01T00:00:00Z", + "username": "johndoe" +} ~~~ \ No newline at end of file diff --git a/build/docker/docker-compose.yaml b/build/docker/docker-compose.yaml index eca7851..444629b 100644 --- a/build/docker/docker-compose.yaml +++ b/build/docker/docker-compose.yaml @@ -12,6 +12,8 @@ services: LOG_LEVEL: DEBUG networks: - backend + ports: + - 8080:8080 depends_on: db: condition: service_healthy diff --git a/build/docker/integrationTest.Dockerfile b/build/docker/integrationTest.Dockerfile deleted file mode 100644 index 8b90a61..0000000 --- a/build/docker/integrationTest.Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -######################### -# Docker wormhole pattern -# Testcontainers will automatically detect if it's inside a container and instead of "localhost" will use the default gateway's IP. -# -# However, additional configuration is required if you use volume mapping. The following points need to be considered: -# - The docker socket must be available via a volume mount -# - The 'local' source code directory must be volume mounted at the same path inside the container that Testcontainers runs in, -# so that Testcontainers is able to set up the correct volume mounts for the containers it spawns. -# -# docker run -it --rm -v $PWD:$PWD -w $PWD -v /var/run/docker.sock:/var/run/docker.sock maven:3 mvn test -######################### -FROM golang:1.21 - -# We might consider disablinbg ruyk when running testcontainers inside container -ENV TESTCONTAINERS_RYUK_DISABLED=true - -RUN apt-get update && apt-get install -y \ - make \ - gcc - -WORKDIR /usr/src/app - -COPY Makefile ./ - -# To /usr/src/app/go.sum -# To /usr/src/app/go.mod -# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change -COPY go.mod go.sum ./ - -RUN go mod download && go mod verify - -COPY . ./ - -RUN ls -lart /usr/src/app/internal/app/bank/integration - -RUN pwd - -ENTRYPOINT [ "make", "integrationtest" ] -CMD [] \ No newline at end of file diff --git a/cmd/bank/main.go b/cmd/bank/main.go index bc85f53..680b519 100644 --- a/cmd/bank/main.go +++ b/cmd/bank/main.go @@ -9,6 +9,8 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/hthunberg/course-golang-postgres-grpc-api/cmd" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/api" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/bank" "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/util" "github.com/jackc/pgx/v5/pgxpool" "go.uber.org/zap" @@ -53,6 +55,19 @@ func main() { runDBMigration(cfg.MigrationURL, cfg.DBSource, logger) + // Set up the bank + bank := bank.NewBank(connPool) + + // Set up the API server for the bank + server, err := api.NewServer(cfg, bank) + if err != nil { + logger.Fatal("initializing: api server", zap.Error(err)) + } + + if err := server.Start(cfg.HTTPServerAddress); err != nil { + logger.Fatal("initializing: start api server", zap.Error(err)) + } + termChan := make(chan os.Signal, 1) signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM) select { diff --git a/dbtest/db.go b/dbtest/db.go index 6bbf584..97e7290 100644 --- a/dbtest/db.go +++ b/dbtest/db.go @@ -8,6 +8,7 @@ import ( "os" "time" + "github.com/docker/go-connections/nat" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" @@ -19,9 +20,11 @@ import ( ) const ( - DbName = "test_db" - DbUser = "test_user" - DbPass = "test_password" + DBName = "test_db" + DBUser = "test_user" + DBPass = "test_password" + DBPort = "5432" + port = DBPort + "/tcp" ) // TestDatabase represents @@ -30,37 +33,49 @@ const ( // - handle to running test container type TestDatabase struct { DbInstance *pgxpool.Pool - DbAddress string - container testcontainers.Container + DBPort string + DBHost string + Container testcontainers.Container } -func SetupTestDatabase() *TestDatabase { +func SetupTestDatabase(ctx context.Context, testDatabaseContainerRequest testcontainers.GenericContainerRequest) (*TestDatabase, error) { // setup db container - ctx, cancel := context.WithTimeout(context.Background(), time.Second*60) - container, dbInstance, dbAddr, err := createContainer(ctx) + container, dbInstance, err := createContainer(ctx, testDatabaseContainerRequest) if err != nil { - log.Fatal("failed to setup test", err) + return nil, fmt.Errorf("setup test db:create container: %v", err) } + dbPort, err := container.MappedPort(ctx, nat.Port(port)) + if err != nil { + return nil, fmt.Errorf("setup test bank:container port: %v", err) + } + + dbHost, err := container.Host(ctx) + if err != nil { + return nil, fmt.Errorf("setup test db:container host: %v", err) + } + + dbAddr := fmt.Sprintf("%s:%s", dbHost, dbPort.Port()) + // migrate db schema err = migrateDb(dbAddr) if err != nil { - log.Fatal("failed to perform db migration", err) + return nil, fmt.Errorf("setup test bank:migrate db: %v", err) } - cancel() return &TestDatabase{ - container: container, + Container: container, DbInstance: dbInstance, - DbAddress: dbAddr, - } + DBPort: dbPort.Port(), + DBHost: dbHost, + }, nil } // TearDown tears down the running database container func (tdb *TestDatabase) TearDown() { tdb.DbInstance.Close() // remove test container - _ = tdb.container.Terminate(context.Background()) + _ = tdb.Container.Terminate(context.Background()) } func (tdb *TestDatabase) Truncate() error { @@ -77,40 +92,50 @@ func (tdb *TestDatabase) Truncate() error { } } - log.Println("database truncated: ", tdb.DbAddress) + dbAddr := fmt.Sprintf("%s:%s", tdb.DBHost, tdb.DBPort) + + log.Println("database truncated: ", dbAddr) return nil } -func createContainer(ctx context.Context) (testcontainers.Container, *pgxpool.Pool, string, error) { +func TestDatabaseContainerRequest() testcontainers.GenericContainerRequest { env := map[string]string{ - "POSTGRES_PASSWORD": DbPass, - "POSTGRES_USER": DbUser, - "POSTGRES_DB": DbName, + "POSTGRES_PASSWORD": DBPass, + "POSTGRES_USER": DBUser, + "POSTGRES_DB": DBName, } - port := "5432/tcp" req := testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "postgres:bullseye", ExposedPorts: []string{port}, Env: env, - WaitingFor: wait.ForLog("database system is ready to accept connections"), + WaitingFor: wait.ForAll( + wait.ForLog("database system is ready to accept connections"), + wait.ForExposedPort().WithStartupTimeout(60*time.Second), + wait.ForListeningPort(nat.Port(port)).WithStartupTimeout(10*time.Second), + ), }, Started: true, } + + return req +} + +func createContainer(ctx context.Context, req testcontainers.GenericContainerRequest) (testcontainers.Container, *pgxpool.Pool, error) { container, err := testcontainers.GenericContainer(ctx, req) if err != nil { - return container, nil, "", fmt.Errorf("failed to start container: %v", err) + return container, nil, fmt.Errorf("create container:failed to start container: %v", err) } p, err := container.MappedPort(ctx, "5432") if err != nil { - return container, nil, "", fmt.Errorf("failed to get container external port: %v", err) + return container, nil, fmt.Errorf("create container:failed to get container external port: %v", err) } h, err := container.Host(ctx) if err != nil { - return container, nil, "", fmt.Errorf("failed to get container host: %v", err) + return container, nil, fmt.Errorf("create container:failed to get container host: %v", err) } time.Sleep(time.Second) @@ -119,24 +144,26 @@ func createContainer(ctx context.Context) (testcontainers.Container, *pgxpool.Po log.Println("postgres container ready and running at: ", dbAddr) - db, err := pgxpool.New(ctx, fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", DbUser, DbPass, dbAddr, DbName)) + db, err := pgxpool.New(ctx, fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", DBUser, DBPass, dbAddr, DBName)) if err != nil { - return container, db, dbAddr, fmt.Errorf("failed to establish database connection: %v", err) + return container, db, fmt.Errorf("create container:failed to establish database connection: %v", err) } - return container, db, dbAddr, nil + return container, db, nil } func migrateDb(dbAddr string) error { - databaseURL := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", DbUser, DbPass, dbAddr, DbName) + databaseURL := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", DBUser, DBPass, dbAddr, DBName) // file:./ to be relative to working directory migrationsURL := os.Getenv("MIGRATION_URL") if len(migrationsURL) < 1 { - return fmt.Errorf("missing env migration_url: %s", migrationsURL) + return fmt.Errorf("migrate db:missing env migration_url: %s", migrationsURL) } + log.Printf("migrate db:running db migrations using db %s migrations %s", databaseURL, migrationsURL) + migration, err := migrate.New(migrationsURL, databaseURL) if err != nil { return err @@ -145,7 +172,7 @@ func migrateDb(dbAddr string) error { err = migration.Up() if err != nil && !errors.Is(err, migrate.ErrNoChange) { - return err + return fmt.Errorf("migrate db:migrate up: %v", err) } log.Println("migration done") diff --git a/go.mod b/go.mod index 2cd4ff2..7692be6 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,27 @@ require ( go.uber.org/zap v1.21.0 ) +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/mock v0.3.0 // indirect + golang.org/x/arch v0.3.0 // indirect +) + require ( dario.cat/mergo v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect @@ -25,6 +46,7 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gin-gonic/gin v1.9.1 github.com/go-ole/go-ole v1.2.6 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect @@ -47,7 +69,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc4 // indirect github.com/opencontainers/runc v1.1.5 // indirect - github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect @@ -64,17 +86,17 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.8.0 // indirect - golang.org/x/crypto v0.13.0 // indirect + golang.org/x/crypto v0.14.0 // indirect golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect - golang.org/x/mod v0.10.0 // indirect - golang.org/x/net v0.15.0 // indirect + golang.org/x/mod v0.11.0 // indirect + golang.org/x/net v0.16.0 // indirect golang.org/x/sync v0.3.0 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.9.1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 // indirect google.golang.org/grpc v1.57.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cd48b02..99b32e0 100644 --- a/go.sum +++ b/go.sum @@ -49,10 +49,16 @@ github.com/Microsoft/hcsshim v0.11.0 h1:7EFNIY4igHEXUdj1zXgAyU3fLc7QfOKHbkldRVTB github.com/Microsoft/hcsshim v0.11.0/go.mod h1:OEthFdQv/AD2RAdzR6Mm1N1KPCztGKDurW1Z8b8VGMM= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -93,11 +99,25 @@ github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -147,6 +167,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -186,12 +207,17 @@ github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -199,12 +225,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/patternmatcher v0.5.0 h1:YCZgJOeULcxLw1Q+sVR636pmS7sPEn1Qo2iAN6M7DBo= @@ -214,6 +244,11 @@ github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5 github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= @@ -225,8 +260,8 @@ github.com/opencontainers/runc v1.1.5 h1:L44KXEpKmfWDcS02aeGm8QNTFXTo2D+8MYGDIJ/ github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg= github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= -github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= -github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -270,7 +305,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= @@ -282,6 +318,10 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= @@ -303,11 +343,16 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= +go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -315,8 +360,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -355,6 +400,7 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -388,8 +434,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -459,12 +505,14 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -631,8 +679,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -655,5 +703,6 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/app/api/account.go b/internal/app/api/account.go new file mode 100644 index 0000000..be95125 --- /dev/null +++ b/internal/app/api/account.go @@ -0,0 +1,40 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/db" +) + +type createAccountRequest struct { + Owner string `json:"owner" binding:"required"` + Currency string `json:"currency" binding:"required"` +} + +func (server *Server) createAccount(ctx *gin.Context) { + var req createAccountRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + arg := db.CreateAccountParams{ + Owner: req.Owner, + Currency: req.Currency, + Balance: 0, + } + + account, err := server.bank.CreateAccount(ctx, arg) + if err != nil { + errCode := db.ErrorCode(err) + if errCode == db.ForeignKeyViolation || errCode == db.UniqueViolation { + ctx.JSON(http.StatusForbidden, errorResponse(err)) + return + } + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + ctx.JSON(http.StatusOK, account) +} diff --git a/internal/app/api/account_test.go b/internal/app/api/account_test.go new file mode 100644 index 0000000..49a7d16 --- /dev/null +++ b/internal/app/api/account_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + mockdb "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/bank/mock" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/db" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/pkg/random" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestCreateAccountAPI(t *testing.T) { + user, _ := randomUser(t) + account := randomAccount(user.Username) + + testCases := []struct { + name string + body gin.H + buildStubs func(store *mockdb.MockBank) + checkResponse func(recoder *httptest.ResponseRecorder) + }{ + { + name: "create-account-OK", + body: gin.H{ + "currency": account.Currency, + "owner": account.Owner, + }, + buildStubs: func(bank *mockdb.MockBank) { + arg := db.CreateAccountParams{ + Owner: account.Owner, + Currency: account.Currency, + Balance: 0, + } + + bank.EXPECT(). + CreateAccount(gomock.Any(), gomock.Eq(arg)). + Times(1). + Return(account, nil) + }, + checkResponse: func(recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusOK, recorder.Code) + requireBodyMatchAccount(t, recorder.Body, account) + }, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + store := mockdb.NewMockBank(ctrl) + tc.buildStubs(store) + + server := newTestServer(t, store) + recorder := httptest.NewRecorder() + + // Marshal body data to JSON + data, err := json.Marshal(tc.body) + require.NoError(t, err) + + url := "/accounts" + request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + require.NoError(t, err) + + server.router.ServeHTTP(recorder, request) + tc.checkResponse(recorder) + }) + } +} + +func randomAccount(owner string) db.Account { + return db.Account{ + ID: random.Int(1000), + Owner: owner, + Balance: random.Money(), + Currency: random.Currency(), + } +} + +func requireBodyMatchAccount(t *testing.T, body *bytes.Buffer, account db.Account) { + data, err := io.ReadAll(body) + require.NoError(t, err) + + var gotAccount db.Account + err = json.Unmarshal(data, &gotAccount) + require.NoError(t, err) + require.Equal(t, account, gotAccount) +} diff --git a/internal/app/api/main_test.go b/internal/app/api/main_test.go new file mode 100644 index 0000000..5c03f3c --- /dev/null +++ b/internal/app/api/main_test.go @@ -0,0 +1,26 @@ +package api + +import ( + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/bank" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/util" + "github.com/stretchr/testify/require" +) + +func newTestServer(t *testing.T, bank bank.Bank) *Server { + config := util.Config{} + + server, err := NewServer(config, bank) + require.NoError(t, err) + + return server +} + +func TestMain(m *testing.M) { + gin.SetMode(gin.TestMode) + + os.Exit(m.Run()) +} diff --git a/internal/app/api/server.go b/internal/app/api/server.go new file mode 100644 index 0000000..bff3f6f --- /dev/null +++ b/internal/app/api/server.go @@ -0,0 +1,44 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/bank" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/util" +) + +// Server serves HTTP requests for our banking service. +type Server struct { + config util.Config + bank bank.Bank + router *gin.Engine +} + +// NewServer creates a new HTTP server and set up routing. +func NewServer(config util.Config, bank bank.Bank) (*Server, error) { + server := &Server{ + config: config, + bank: bank, + } + + server.setupRouter() + return server, nil +} + +func (server *Server) setupRouter() { + router := gin.Default() + + router.POST("/users", server.createUser) + router.POST("/accounts", server.createAccount) + + server.router = router +} + +// Start runs the HTTP server on a specific address. +func (server *Server) Start(address string) error { + return server.router.Run(address) +} + +// errorResponse formats the errors returned to the client. +func errorResponse(err error) gin.H { + return gin.H{"error": err.Error()} +} diff --git a/internal/app/api/user.go b/internal/app/api/user.go new file mode 100644 index 0000000..000eebe --- /dev/null +++ b/internal/app/api/user.go @@ -0,0 +1,69 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/db" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/pkg/password" +) + +type createUserRequest struct { + Username string `json:"username" binding:"required,alphanum"` + Password string `json:"password" binding:"required,min=6"` + FullName string `json:"full_name" binding:"required"` + Email string `json:"email" binding:"required,email"` +} + +type userResponse struct { + Username string `json:"username"` + FullName string `json:"full_name"` + Email string `json:"email"` + PasswordChangedAt time.Time `json:"password_changed_at"` + CreatedAt time.Time `json:"created_at"` +} + +func newUserResponse(user db.User) userResponse { + return userResponse{ + Username: user.Username, + FullName: user.FullName, + Email: user.Email, + PasswordChangedAt: user.PasswordChangedAt, + CreatedAt: user.CreatedAt, + } +} + +func (server *Server) createUser(ctx *gin.Context) { + var req createUserRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + hashedPassword, err := password.HashPassword(req.Password) + if err != nil { + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + arg := db.CreateUserParams{ + Username: req.Username, + HashedPassword: hashedPassword, + FullName: req.FullName, + Email: req.Email, + } + + user, err := server.bank.CreateUser(ctx, arg) + if err != nil { + if db.ErrorCode(err) == db.UniqueViolation { + ctx.JSON(http.StatusForbidden, errorResponse(err)) + return + } + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + rsp := newUserResponse(user) + ctx.JSON(http.StatusOK, rsp) +} diff --git a/internal/app/api/user_test.go b/internal/app/api/user_test.go new file mode 100644 index 0000000..1dc7a33 --- /dev/null +++ b/internal/app/api/user_test.go @@ -0,0 +1,139 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/mock/gomock" + + mockdb "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/bank/mock" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/db" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/pkg/password" + "github.com/hthunberg/course-golang-postgres-grpc-api/internal/pkg/random" + "github.com/stretchr/testify/require" +) + +type eqCreateUserParamsMatcher struct { + arg db.CreateUserParams + password string +} + +func (e eqCreateUserParamsMatcher) Matches(x interface{}) bool { + arg, ok := x.(db.CreateUserParams) + if !ok { + return false + } + + err := password.CheckPassword(e.password, arg.HashedPassword) + if err != nil { + return false + } + + e.arg.HashedPassword = arg.HashedPassword + return reflect.DeepEqual(e.arg, arg) +} + +func (e eqCreateUserParamsMatcher) String() string { + return fmt.Sprintf("matches arg %v and password %v", e.arg, e.password) +} + +func EqCreateUserParams(arg db.CreateUserParams, password string) gomock.Matcher { + return eqCreateUserParamsMatcher{arg, password} +} + +func TestCreateUserAPI(t *testing.T) { + user, password := randomUser(t) + + testCases := []struct { + name string + body gin.H + buildStubs func(store *mockdb.MockBank) + checkResponse func(recoder *httptest.ResponseRecorder) + }{ + { + name: "OK", + body: gin.H{ + "username": user.Username, + "password": password, + "full_name": user.FullName, + "email": user.Email, + }, + buildStubs: func(store *mockdb.MockBank) { + arg := db.CreateUserParams{ + Username: user.Username, + FullName: user.FullName, + Email: user.Email, + } + store.EXPECT(). + CreateUser(gomock.Any(), EqCreateUserParams(arg, password)). + Times(1). + Return(user, nil) + }, + checkResponse: func(recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusOK, recorder.Code) + requireBodyMatchUser(t, recorder.Body, user) + }, + }, + } + + for i := range testCases { + tc := testCases[i] + + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + store := mockdb.NewMockBank(ctrl) + tc.buildStubs(store) + + server := newTestServer(t, store) + recorder := httptest.NewRecorder() + + // Marshal body data to JSON + data, err := json.Marshal(tc.body) + require.NoError(t, err) + + url := "/users" + request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + require.NoError(t, err) + + server.router.ServeHTTP(recorder, request) + tc.checkResponse(recorder) + }) + } +} + +func randomUser(t *testing.T) (user db.User, pwd string) { + pwd = random.String(6) + hashedPassword, err := password.HashPassword(pwd) + require.NoError(t, err) + + user = db.User{ + Username: random.Owner(), + HashedPassword: hashedPassword, + FullName: random.Owner(), + Email: random.Email(), + } + return +} + +func requireBodyMatchUser(t *testing.T, body *bytes.Buffer, user db.User) { + data, err := io.ReadAll(body) + require.NoError(t, err) + + var gotUser db.User + err = json.Unmarshal(data, &gotUser) + + require.NoError(t, err) + require.Equal(t, user.Username, gotUser.Username) + require.Equal(t, user.FullName, gotUser.FullName) + require.Equal(t, user.Email, gotUser.Email) + require.Empty(t, gotUser.HashedPassword) +} diff --git a/internal/app/bank/integration/main_test.go b/internal/app/bank/integration/main_test.go index 23cbe29..df3680f 100644 --- a/internal/app/bank/integration/main_test.go +++ b/internal/app/bank/integration/main_test.go @@ -3,7 +3,9 @@ package integration import ( + "context" "fmt" + "log" "os" "testing" @@ -16,7 +18,14 @@ var testee bank.Bank func TestMain(m *testing.M) { os.Setenv("MIGRATION_URL", fmt.Sprintf("file:.%s", "/../../../../build/db/migrations")) - testDB := dbtest.SetupTestDatabase() + ctx := context.Background() + + // Set up a postgres DB + testDBRequest := dbtest.TestDatabaseContainerRequest() + testDB, err := dbtest.SetupTestDatabase(ctx, testDBRequest) + if err != nil { + log.Fatal("failed to setup postgres db", err) + } defer testDB.TearDown() testee = bank.NewBank(testDB.DbInstance) diff --git a/internal/app/bank/mock/bank.go b/internal/app/bank/mock/bank.go new file mode 100644 index 0000000..5b4645e --- /dev/null +++ b/internal/app/bank/mock/bank.go @@ -0,0 +1,310 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/app/bank/bank.go +// +// Generated by this command: +// +// mockgen -source internal/app/bank/bank.go -destination internal/app/bank/mock/bank.go -package mockdb +// +// Package mockdb is a generated GoMock package. +package mockdb + +import ( + context "context" + reflect "reflect" + + bank "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/bank" + db "github.com/hthunberg/course-golang-postgres-grpc-api/internal/app/db" + gomock "go.uber.org/mock/gomock" +) + +// MockBank is a mock of Bank interface. +type MockBank struct { + ctrl *gomock.Controller + recorder *MockBankMockRecorder +} + +// MockBankMockRecorder is the mock recorder for MockBank. +type MockBankMockRecorder struct { + mock *MockBank +} + +// NewMockBank creates a new mock instance. +func NewMockBank(ctrl *gomock.Controller) *MockBank { + mock := &MockBank{ctrl: ctrl} + mock.recorder = &MockBankMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBank) EXPECT() *MockBankMockRecorder { + return m.recorder +} + +// AddAccountBalance mocks base method. +func (m *MockBank) AddAccountBalance(ctx context.Context, arg db.AddAccountBalanceParams) (db.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddAccountBalance", ctx, arg) + ret0, _ := ret[0].(db.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddAccountBalance indicates an expected call of AddAccountBalance. +func (mr *MockBankMockRecorder) AddAccountBalance(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAccountBalance", reflect.TypeOf((*MockBank)(nil).AddAccountBalance), ctx, arg) +} + +// AddUser mocks base method. +func (m *MockBank) AddUser(ctx context.Context, arg bank.AddUserParams) (bank.AddUserResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddUser", ctx, arg) + ret0, _ := ret[0].(bank.AddUserResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddUser indicates an expected call of AddUser. +func (mr *MockBankMockRecorder) AddUser(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUser", reflect.TypeOf((*MockBank)(nil).AddUser), ctx, arg) +} + +// CreateAccount mocks base method. +func (m *MockBank) CreateAccount(ctx context.Context, arg db.CreateAccountParams) (db.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAccount", ctx, arg) + ret0, _ := ret[0].(db.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateAccount indicates an expected call of CreateAccount. +func (mr *MockBankMockRecorder) CreateAccount(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccount", reflect.TypeOf((*MockBank)(nil).CreateAccount), ctx, arg) +} + +// CreateEntry mocks base method. +func (m *MockBank) CreateEntry(ctx context.Context, arg db.CreateEntryParams) (db.Entry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateEntry", ctx, arg) + ret0, _ := ret[0].(db.Entry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateEntry indicates an expected call of CreateEntry. +func (mr *MockBankMockRecorder) CreateEntry(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateEntry", reflect.TypeOf((*MockBank)(nil).CreateEntry), ctx, arg) +} + +// CreateTransfer mocks base method. +func (m *MockBank) CreateTransfer(ctx context.Context, arg db.CreateTransferParams) (db.Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTransfer", ctx, arg) + ret0, _ := ret[0].(db.Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTransfer indicates an expected call of CreateTransfer. +func (mr *MockBankMockRecorder) CreateTransfer(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTransfer", reflect.TypeOf((*MockBank)(nil).CreateTransfer), ctx, arg) +} + +// CreateUser mocks base method. +func (m *MockBank) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUser", ctx, arg) + ret0, _ := ret[0].(db.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUser indicates an expected call of CreateUser. +func (mr *MockBankMockRecorder) CreateUser(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockBank)(nil).CreateUser), ctx, arg) +} + +// DeleteAccount mocks base method. +func (m *MockBank) DeleteAccount(ctx context.Context, id int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccount", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccount indicates an expected call of DeleteAccount. +func (mr *MockBankMockRecorder) DeleteAccount(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccount", reflect.TypeOf((*MockBank)(nil).DeleteAccount), ctx, id) +} + +// GetAccount mocks base method. +func (m *MockBank) GetAccount(ctx context.Context, id int64) (db.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccount", ctx, id) + ret0, _ := ret[0].(db.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccount indicates an expected call of GetAccount. +func (mr *MockBankMockRecorder) GetAccount(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockBank)(nil).GetAccount), ctx, id) +} + +// GetAccountForUpdate mocks base method. +func (m *MockBank) GetAccountForUpdate(ctx context.Context, id int64) (db.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountForUpdate", ctx, id) + ret0, _ := ret[0].(db.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountForUpdate indicates an expected call of GetAccountForUpdate. +func (mr *MockBankMockRecorder) GetAccountForUpdate(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountForUpdate", reflect.TypeOf((*MockBank)(nil).GetAccountForUpdate), ctx, id) +} + +// GetEntry mocks base method. +func (m *MockBank) GetEntry(ctx context.Context, id int64) (db.Entry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEntry", ctx, id) + ret0, _ := ret[0].(db.Entry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEntry indicates an expected call of GetEntry. +func (mr *MockBankMockRecorder) GetEntry(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEntry", reflect.TypeOf((*MockBank)(nil).GetEntry), ctx, id) +} + +// GetTransfer mocks base method. +func (m *MockBank) GetTransfer(ctx context.Context, id int64) (db.Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransfer", ctx, id) + ret0, _ := ret[0].(db.Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransfer indicates an expected call of GetTransfer. +func (mr *MockBankMockRecorder) GetTransfer(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransfer", reflect.TypeOf((*MockBank)(nil).GetTransfer), ctx, id) +} + +// GetUser mocks base method. +func (m *MockBank) GetUser(ctx context.Context, username string) (db.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUser", ctx, username) + ret0, _ := ret[0].(db.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUser indicates an expected call of GetUser. +func (mr *MockBankMockRecorder) GetUser(ctx, username any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockBank)(nil).GetUser), ctx, username) +} + +// ListAccounts mocks base method. +func (m *MockBank) ListAccounts(ctx context.Context, arg db.ListAccountsParams) ([]db.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAccounts", ctx, arg) + ret0, _ := ret[0].([]db.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAccounts indicates an expected call of ListAccounts. +func (mr *MockBankMockRecorder) ListAccounts(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAccounts", reflect.TypeOf((*MockBank)(nil).ListAccounts), ctx, arg) +} + +// ListEntries mocks base method. +func (m *MockBank) ListEntries(ctx context.Context, arg db.ListEntriesParams) ([]db.Entry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListEntries", ctx, arg) + ret0, _ := ret[0].([]db.Entry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListEntries indicates an expected call of ListEntries. +func (mr *MockBankMockRecorder) ListEntries(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListEntries", reflect.TypeOf((*MockBank)(nil).ListEntries), ctx, arg) +} + +// ListTransfers mocks base method. +func (m *MockBank) ListTransfers(ctx context.Context, arg db.ListTransfersParams) ([]db.Transfer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTransfers", ctx, arg) + ret0, _ := ret[0].([]db.Transfer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListTransfers indicates an expected call of ListTransfers. +func (mr *MockBankMockRecorder) ListTransfers(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTransfers", reflect.TypeOf((*MockBank)(nil).ListTransfers), ctx, arg) +} + +// Transfer mocks base method. +func (m *MockBank) Transfer(ctx context.Context, arg bank.TransferParams) (bank.TransferResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Transfer", ctx, arg) + ret0, _ := ret[0].(bank.TransferResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Transfer indicates an expected call of Transfer. +func (mr *MockBankMockRecorder) Transfer(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transfer", reflect.TypeOf((*MockBank)(nil).Transfer), ctx, arg) +} + +// UpdateAccount mocks base method. +func (m *MockBank) UpdateAccount(ctx context.Context, arg db.UpdateAccountParams) (db.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccount", ctx, arg) + ret0, _ := ret[0].(db.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAccount indicates an expected call of UpdateAccount. +func (mr *MockBankMockRecorder) UpdateAccount(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccount", reflect.TypeOf((*MockBank)(nil).UpdateAccount), ctx, arg) +} + +// UpdateUser mocks base method. +func (m *MockBank) UpdateUser(ctx context.Context, arg db.UpdateUserParams) (db.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUser", ctx, arg) + ret0, _ := ret[0].(db.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUser indicates an expected call of UpdateUser. +func (mr *MockBankMockRecorder) UpdateUser(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockBank)(nil).UpdateUser), ctx, arg) +} diff --git a/internal/app/db/error.go b/internal/app/db/error.go new file mode 100644 index 0000000..6efdb80 --- /dev/null +++ b/internal/app/db/error.go @@ -0,0 +1,36 @@ +package db + +import ( + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +const ( + // PostgreSQL Error Codes + // https://www.postgresql.org/docs/current/errcodes-appendix.html + ForeignKeyViolation = "23503" + UniqueViolation = "23505" +) + +var ErrRecordNotFound = pgx.ErrNoRows + +var ErrUniqueViolation = &pgconn.PgError{ + Code: UniqueViolation, +} + +var ErrForeignKeyViolation = &pgconn.PgError{ + Code: ForeignKeyViolation, +} + +// ErrorCode returns the db error code matching the first error in err's tree that matches. +func ErrorCode(err error) string { + var pgErr *pgconn.PgError + + // Find the first error in err's tree that matches target + if errors.As(err, &pgErr) { + return pgErr.Code + } + return "" +} diff --git a/internal/app/util/config.go b/internal/app/util/config.go index fd3e86b..3f75ddb 100644 --- a/internal/app/util/config.go +++ b/internal/app/util/config.go @@ -10,10 +10,11 @@ import ( // The values are read by viper from a config file or environment variable. // Viper uses the mapstructure package under the hood for unmarshal of values. type Config struct { - Environment string `mapstructure:"ENVIRONMENT"` - DBSource string `mapstructure:"DB_SOURCE"` - MigrationURL string `mapstructure:"MIGRATION_URL"` - LogLevel string `mapstructure:"LOG_LEVEL"` + Environment string `mapstructure:"ENVIRONMENT"` + DBSource string `mapstructure:"DB_SOURCE"` + MigrationURL string `mapstructure:"MIGRATION_URL"` + LogLevel string `mapstructure:"LOG_LEVEL"` + HTTPServerAddress string `mapstructure:"HTTP_SERVER_ADDRESS"` } // LoadConfig reads configuration from file or environment variables. @@ -36,6 +37,7 @@ func LoadConfig(path string) (config Config, err error) { viper.SetDefault("DB_SOURCE", "postgresql://postgres:postgres@localhost:5432/bankdb?sslmode=disable") viper.SetDefault("MIGRATION_URL", "file://migrations") viper.SetDefault("LOG_LEVEL", "INFO") + viper.SetDefault("HTTP_SERVER_ADDRESS", "0.0.0.0:8080") // Tell Viper to read config from file. if err := viper.ReadInConfig(); err != nil { diff --git a/internal/pkg/password/password.go b/internal/pkg/password/password.go new file mode 100644 index 0000000..7088ad6 --- /dev/null +++ b/internal/pkg/password/password.go @@ -0,0 +1,21 @@ +package password + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// HashPassword returns the bcrypt hash of the password +func HashPassword(password string) (string, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(hashedPassword), nil +} + +// CheckPassword checks if the provided password is correct or not +func CheckPassword(password string, hashedPassword string) error { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) +} diff --git a/test/integration/README.md b/test/integration/README.md index 0e6b865..2a80bdb 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -1,6 +1,4 @@ # testcontainers-go -Currently just a testcontainers-go skeleton, no tests. - -* https://github.com/testcontainers/testcontainers-go +https://github.com/testcontainers/testcontainers-go diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index 7104dec..3a13677 100644 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -3,9 +3,13 @@ package integration import ( + "bytes" "context" + "encoding/json" + "net/http" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -13,3 +17,85 @@ func TestSomeIntegration(t *testing.T) { err := testDbInstance.Ping(context.Background()) require.NoError(t, err) } + +func TestCreateUser(t *testing.T) { + bankClient, err := newTestBankCLient(testBankBaseURL) + require.NoError(t, err) + + userReq := UserRequest{ + UserName: "johndoe", + Password: "qwerty", + FullName: "John Doe", + Email: "john.doe@testbank.qwerty", + } + + userReqJson, err := marshalJson(userReq) + require.NoError(t, err) + + res, resBody, err := bankClient.createUser(bytes.NewReader(userReqJson)) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, res.StatusCode) + + m, err := unMarshalJson(resBody) + require.NoError(t, err) + + assertJsonElement(t, m, "username", userReq.UserName) + assertJsonElement(t, m, "full_name", userReq.FullName) + assertJsonElement(t, m, "email", userReq.Email) +} + +func TestCreateUserAccount(t *testing.T) { + bankClient, err := newTestBankCLient(testBankBaseURL) + require.NoError(t, err) + + userReq := UserRequest{ + UserName: "johndoe", + Password: "qwerty", + FullName: "John Doe", + Email: "john.doe@testbank.qwerty", + } + + userReqJson, err := marshalJson(userReq) + require.NoError(t, err) + + _, _, err = bankClient.createUser(bytes.NewReader(userReqJson)) + require.NoError(t, err) + + accountReq := AccountRequest{ + Owner: "johndoe", + Currency: "SEK", + } + + accountReqJson, err := marshalJson(accountReq) + require.NoError(t, err) + + res, resBody, err := bankClient.createAccount(bytes.NewReader(accountReqJson)) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, res.StatusCode) + + m, err := unMarshalJson(resBody) + require.NoError(t, err) + + assertJsonElement(t, m, "owner", accountReq.Owner) + assertJsonElement(t, m, "currency", accountReq.Currency) + assertJsonElement(t, m, "balance", float64(0)) +} + +func assertJsonElement(t *testing.T, m map[string]any, key string, expected any) { + actual, exists := m[key] + assert.True(t, exists) + assert.Equal(t, expected, actual) +} + +func marshalJson(v any) ([]byte, error) { + buffer, err := json.Marshal(v) + return buffer, err +} + +func unMarshalJson(v []byte) (map[string]any, error) { + var resp map[string]any + err := json.Unmarshal(v, &resp) + return resp, err +} diff --git a/test/integration/logconsumer.go b/test/integration/logconsumer.go new file mode 100644 index 0000000..9aaacd0 --- /dev/null +++ b/test/integration/logconsumer.go @@ -0,0 +1,51 @@ +package integration + +import ( + "fmt" + + tc "github.com/testcontainers/testcontainers-go" +) + +const lastMessage = "DONE" + +type TestLogConsumer struct { + Msgs []string + Done chan bool + + // Accepted provides a blocking way of ensuring the logs messages have been consumed. + // This allows for proper synchronization during Test_StartStop in particular. + // Please see func devNullAcceptorChan if you're not interested in this synchronization. + Accepted chan string +} + +func newTestLogConsumer(msgs []string, done chan bool) TestLogConsumer { + return TestLogConsumer{ + Msgs: msgs, + Done: done, + Accepted: devNullAcceptorChan(), + } +} + +func (g *TestLogConsumer) Accept(l tc.Log) { + // fmt.Println(string(l.Content)) + s := string(l.Content) + if s == fmt.Sprintf("echo %s\n", lastMessage) { + g.Done <- true + return + } + g.Accepted <- s + g.Msgs = append(g.Msgs, s) +} + +// devNullAcceptorChan returns string channel that essentially sends all strings to dev null +func devNullAcceptorChan() chan string { + c := make(chan string) + go func(c <-chan string) { + //revive:disable + for range c { + // do nothing, just pull off channel + } + //revive:enable + }(c) + return c +} diff --git a/test/integration/main_test.go b/test/integration/main_test.go index cf11c7c..2b6ea6a 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -3,21 +3,62 @@ package integration import ( + "context" "fmt" + "log" "os" "testing" + "time" "github.com/hthunberg/course-golang-postgres-grpc-api/dbtest" "github.com/jackc/pgx/v5/pgxpool" ) -var testDbInstance *pgxpool.Pool +var ( + testDbInstance *pgxpool.Pool + testBankBaseURL string +) func TestMain(m *testing.M) { os.Setenv("MIGRATION_URL", fmt.Sprintf("file:.%s", "/../../build/db/migrations")) - testDB := dbtest.SetupTestDatabase() + ctx := context.Background() + + // Docker provides the ability for us to create custom networks and place containers on one or more networks. + // The communication can then occur between networked containers without the need of exposing ports through the host. + // In this particular setup TestBank can access the TestDB using network alias "testdb" and its internal port 5432. + network, err := CreateNetwork(ctx) + if err != nil { + log.Fatal("failed to setup test network", err) + } + defer network.TearDown(ctx) + + // Alias for postgres db when running inside a custom network, + testDBAlias := "testdb" + + // Set up a postgres DB + testDBRequest := dbtest.TestDatabaseContainerRequest() + network.ApplyNetworkAlias(&testDBRequest, testDBAlias) + testDB, err := dbtest.SetupTestDatabase(ctx, testDBRequest) + if err != nil { + log.Fatal("failed to setup postgres db", err) + } defer testDB.TearDown() testDbInstance = testDB.DbInstance + + // Set up a test bank + dbAddr := fmt.Sprintf("%s:%s", testDBAlias, dbtest.DBPort) + testBankRequest := TestBankContainerRequest(dbAddr) + network.ApplyNetworkAlias(&testBankRequest, "testbank") + testBank, err := setupTestBank(ctx, testBankRequest) + if err != nil { + log.Fatal("failed to setup test bank", err) + } + defer testBank.TearDown() + + testBankBaseURL = testBank.URI + + time.Sleep(time.Second) + os.Exit(m.Run()) } diff --git a/test/integration/network.go b/test/integration/network.go new file mode 100644 index 0000000..3c32f38 --- /dev/null +++ b/test/integration/network.go @@ -0,0 +1,52 @@ +package integration + +import ( + "context" + "fmt" + + "github.com/google/uuid" + tc "github.com/testcontainers/testcontainers-go" +) + +type Network struct { + tc.Network + Name string +} + +// CreateNetwork creates a network with a random name +func CreateNetwork(ctx context.Context) (*Network, error) { + networkName := fmt.Sprintf("net_%s", uuid.New()) + + n, err := tc.GenericNetwork(ctx, tc.GenericNetworkRequest{ + NetworkRequest: tc.NetworkRequest{ + Name: networkName, + CheckDuplicate: true, + }, + }) + if err != nil { + return nil, err + } + + return &Network{n, networkName}, nil +} + +// TearDown tears down the network +func (n *Network) TearDown(ctx context.Context) { + _ = n.Network.Remove(ctx) +} + +// ApplyNetworkAlias applies a network alias to a generic container request +func (n *Network) ApplyNetworkAlias(req *tc.GenericContainerRequest, alias string) { + if req.Networks == nil { + req.Networks = make([]string, 0) + } + req.Networks = append(req.Networks, n.Name) + + if req.NetworkAliases == nil { + req.NetworkAliases = make(map[string][]string) + } + if _, ok := req.NetworkAliases[n.Name]; !ok { + req.NetworkAliases[n.Name] = make([]string, 0) + } + req.NetworkAliases[n.Name] = append(req.NetworkAliases[n.Name], alias) +} diff --git a/test/integration/testbank.go b/test/integration/testbank.go new file mode 100644 index 0000000..4196567 --- /dev/null +++ b/test/integration/testbank.go @@ -0,0 +1,155 @@ +package integration + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/hthunberg/course-golang-postgres-grpc-api/dbtest" + tc "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +type TestBank struct { + container tc.Container + URI string +} + +func setupTestBank(ctx context.Context, testBankContainerRequest tc.GenericContainerRequest) (*TestBank, error) { + container, err := tc.GenericContainer(ctx, testBankContainerRequest) + if err != nil { + return nil, fmt.Errorf("setup test bank:create container: %v", err) + } + + ip, err := container.Host(ctx) + if err != nil { + return nil, fmt.Errorf("setup test bank:container host: %v", err) + } + + mappedPort, err := container.MappedPort(ctx, "8080") + if err != nil { + return nil, fmt.Errorf("setup test bank:container port: %v", err) + } + + uri := fmt.Sprintf("http://%s:%s", ip, mappedPort.Port()) + + // Follow application logs + lc := newTestLogConsumer([]string{}, make(chan bool)) + container.FollowOutput(&lc) + + _ = container.StartLogProducer(ctx) + + return &TestBank{container: container, URI: uri}, nil +} + +// TearDown tears down the running bank container +func (tdb *TestBank) TearDown() { + _ = tdb.container.Terminate(context.Background()) +} + +func TestBankContainerRequest(dbAddr string) tc.GenericContainerRequest { + dbSource := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", dbtest.DBUser, dbtest.DBPass, dbAddr, dbtest.DBName) + + // TODO: Hans fixa + // file:./ to be relative to working directory + // hostPathMigrationsURL := os.Getenv("MIGRATION_URL") + hostPathMigrationsURL := "/Users/hansthunberg/git-views/golang/course-golang-postgres-grpc-api/build/db/migrations" + + env := map[string]string{ + "ENVIRONMENT": "integrationtest", + "DB_SOURCE": dbSource, + "MIGRATION_URL": "file://migrations", + "LOG_LEVEL": "DEBUG", + } + port := "8080/tcp" + + req := tc.GenericContainerRequest{ + ContainerRequest: tc.ContainerRequest{ + Name: "testbank", + Image: "bank:latest", + ExposedPorts: []string{port}, + Env: env, + Mounts: tc.ContainerMounts{ + tc.ContainerMount{ + Source: tc.GenericBindMountSource{ + HostPath: hostPathMigrationsURL, + }, + Target: tc.ContainerMountTarget("/app/bin/migrations"), + }, + }, + WaitingFor: wait.ForAll( + wait.ForLog("initializing: starting application").WithStartupTimeout(5 * time.Second), + ), + }, + Started: true, + } + + return req +} + +type TestBankClient struct { + httpClient http.Client + baseURL string +} + +func newTestBankCLient(baseURL string) (*TestBankClient, error) { + return &TestBankClient{httpClient: *http.DefaultClient, baseURL: baseURL}, nil +} + +func (t *TestBankClient) createUser(reqBody io.Reader) (res *http.Response, body []byte, err error) { + req, err := http.NewRequest( + "POST", + t.baseURL+"/users", + reqBody) + if err != nil { + return nil, nil, fmt.Errorf("create user:new request: %v", err) + } + + if res, err = t.httpClient.Do(req); err != nil { + return nil, nil, fmt.Errorf("create user:do request: %v", err) + } + + if body, err = io.ReadAll(res.Body); err != nil { + return nil, nil, fmt.Errorf("create user:read response: %v", err) + } + + _ = res.Body.Close() + + return res, body, nil +} + +func (t *TestBankClient) createAccount(reqBody io.Reader) (res *http.Response, body []byte, err error) { + req, err := http.NewRequest( + "POST", + t.baseURL+"/accounts", + reqBody) + if err != nil { + return nil, nil, fmt.Errorf("create account:new request: %v", err) + } + + if res, err = t.httpClient.Do(req); err != nil { + return nil, nil, fmt.Errorf("create account:do request: %v", err) + } + + if body, err = io.ReadAll(res.Body); err != nil { + return nil, nil, fmt.Errorf("create account:read response: %v", err) + } + + _ = res.Body.Close() + + return res, body, nil +} + +type UserRequest struct { + UserName string `json:"username"` + Password string `json:"password"` + FullName string `json:"full_name"` + Email string `json:"email"` +} + +type AccountRequest struct { + Owner string `json:"owner"` + Currency string `json:"currency"` +}