diff --git a/Makefile b/Makefile index 9575e95..1e830e1 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,15 @@ lint: .PHONY: test test: echo 'Running tests...' + + export POSTGRES_DB=godb && \ + export POSTGRES_USER=nikongo && \ + export POSTGRES_PASSWORD=go && \ + export POSTGRES_PORT=5432 && \ + export POSTGRES_HOST=localhost && \ + export GRPC_PORT=8080 && \ + export GRPC_GATEWAY_PORT=8081 && \ + export POSTGRES_MAX_CONN=10 && \ ${GO_TEST} ${GO_TEST_ARGS} .PHONY: update @@ -88,7 +97,8 @@ bin-deps: .bin-deps go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1 && \ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2.0 && \ go install golang.org/x/tools/cmd/goimports@v0.19.0 && \ - go install github.com/envoyproxy/protoc-gen-validate@v1.2.1 + go install github.com/envoyproxy/protoc-gen-validate@v1.2.1 && \ + go install go.uber.org/mock/mockgen@latest .create-bin: rm -rf ./bin @@ -106,11 +116,15 @@ fast-generate: .generate rm -rf ./docs/spec mkdir -p ./docs/spec + rm -rf ./mocks-generated + mkdir ./mocks-generated + rm -rf ~/.easyp/ (PATH="$(PATH):$(LOCAL_BIN)" && $(EASYP_BIN) mod download && $(EASYP_BIN) generate) $(GOIMPORTS_BIN) -w . + go generate ./... build: go mod tidy diff --git a/api/library/library.proto b/api/library/library.proto index e69de29..1deb1d3 100644 --- a/api/library/library.proto +++ b/api/library/library.proto @@ -0,0 +1,170 @@ +syntax = "proto3"; + +import "google/api/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +package library; + +option go_package = "github.com/project/library/pkg/api/library;library"; + +service Library { + rpc AddBook(AddBookRequest) returns (AddBookResponse) { + option (google.api.http) = { + post: "/v1/library/book" + body: "*" + }; + } + + rpc UpdateBook(UpdateBookRequest) returns (UpdateBookResponse) { + option (google.api.http) = { + put: "/v1/library/book" + body: "*" + }; + } + + rpc GetBookInfo(GetBookInfoRequest) returns (GetBookInfoResponse) { + option (google.api.http) = { + get: "/v1/library/book/{id}" + }; + } + + rpc RegisterAuthor(RegisterAuthorRequest) returns (RegisterAuthorResponse) { + option (google.api.http) = { + post: "/v1/library/author" + body: "*" + }; + } + + rpc ChangeAuthorInfo(ChangeAuthorInfoRequest) returns (ChangeAuthorInfoResponse) { + option (google.api.http) = { + put: "/v1/library/author" + body: "*" + }; + } + + rpc GetAuthorInfo(GetAuthorInfoRequest) returns (GetAuthorInfoResponse) { + option (google.api.http) = { + get: "/v1/library/author/{id}" + }; + } + + rpc GetAuthorBooks(GetAuthorBooksRequest) returns (stream Book) { + option (google.api.http) = { + get: "/v1/library/author_books/{author_id}" + }; + } +} + +message Book { + string id = 1 [ + (validate.rules).string.uuid = true + ]; + + string name = 2; + + repeated string author_id = 3 [ + (validate.rules).repeated = { + min_items: 0, + items: { + string: {uuid: true} + } + } + ]; + + google.protobuf.Timestamp created_at = 4; + google.protobuf.Timestamp updated_at = 5; +} + +message AddBookRequest { + string name = 1; + + repeated string author_ids = 2 [ + (validate.rules).repeated = { + min_items: 0, + items: { + string: {uuid: true} + } + } + ]; +} + +message AddBookResponse { + Book book = 1; +} + +message UpdateBookRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; + + string name = 2; + + repeated string author_ids = 3 [ + (validate.rules).repeated = { + min_items: 0, + items: { + string: {uuid: true} + } + } + ]; +} + +message UpdateBookResponse {} + +message GetBookInfoRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; +} + +message GetBookInfoResponse { + Book book = 1; +} + +message RegisterAuthorRequest { + string name = 1 [ + (validate.rules).string = { + pattern: "^[A-Za-z0-9]+( [A-Za-z0-9]+)*$", + min_len: 1, + max_len: 512 + } + ]; +} + +message RegisterAuthorResponse { + string id = 1; +} + +message ChangeAuthorInfoRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; + string name = 2 [ + (validate.rules).string = { + pattern: "^[A-Za-z0-9]+( [A-Za-z0-9]+)*$", + min_len: 1, + max_len: 512 + } + ]; +} + +message ChangeAuthorInfoResponse {} + + +message GetAuthorInfoRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; +} + +message GetAuthorInfoResponse { + string id = 1; + string name = 2; +} + +message GetAuthorBooksRequest { + string author_id = 1 [ + (validate.rules).string.uuid = true + ]; +} \ No newline at end of file diff --git a/cmd/library/main.go b/cmd/library/main.go index a46007b..631b9f7 100644 --- a/cmd/library/main.go +++ b/cmd/library/main.go @@ -8,7 +8,7 @@ import ( ) func main() { - cfg, err := config.NewConfig() + cfg, err := config.New() if err != nil { log.Fatalf("can not get application config: %s", err) diff --git a/config/config.go b/config/config.go index 4007154..1016ee6 100644 --- a/config/config.go +++ b/config/config.go @@ -1,18 +1,152 @@ package config +import ( + "errors" + "fmt" + "net" + "net/url" + "os" +) + type ( Config struct { GRPC + PG } GRPC struct { Port string `env:"GRPC_PORT"` GatewayPort string `env:"GRPC_GATEWAY_PORT"` } + + PG struct { + URL string + Host string `env:"POSTGRES_HOST"` + Port string `env:"POSTGRES_PORT"` + DB string `env:"POSTGRES_DB"` + User string `env:"POSTGRES_USER"` + Password string `env:"POSTGRES_PASSWORD"` + MaxConn string `env:"POSTGRES_MAX_CONN"` + } +) + +var ( + ErrGRPCPortNotSet = errors.New("GRPC_PORT environment variable not set") + ErrGRPCGatewayPortNotSet = errors.New("GRPC_GATEWAY_PORT environment variable not set") + ErrPostgresHostNotSet = errors.New("POSTGRES_HOST environment variable not set") + ErrPostgresPortNotSet = errors.New("POSTGRES_PORT environment variable not set") + ErrPostgresDBNotSet = errors.New("POSTGRES_DB environment variable not set") + ErrPostgresUserNotSet = errors.New("POSTGRES_USER environment variable not set") + ErrPostgresPasswordNotSet = errors.New("POSTGRES_PASSWORD environment variable not set") + ErrPostgresMaxConnNotSet = errors.New("POSTGRES_MAX_CONN environment variable not set") ) -func NewConfig() (*Config, error) { +func New() (*Config, error) { cfg := &Config{} + if err := cfg.readGrpcPort(); err != nil { + return nil, err + } + if err := cfg.readGrpcGatewayPort(); err != nil { + return nil, err + } + if err := cfg.readPGHost(); err != nil { + return nil, err + } + if err := cfg.readPGUser(); err != nil { + return nil, err + } + if err := cfg.readPGPort(); err != nil { + return nil, err + } + if err := cfg.readPGPassword(); err != nil { + return nil, err + } + if err := cfg.readPGDB(); err != nil { + return nil, err + } + if err := cfg.readPGMaxConn(); err != nil { + return nil, err + } + + cfg.PG.URL = fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", + url.PathEscape(cfg.PG.User), + url.PathEscape(cfg.PG.Password), + net.JoinHostPort(cfg.PG.Host, cfg.PG.Port), + cfg.PG.DB, + ) + return cfg, nil } + +func (config *Config) readGrpcPort() error { + var ok bool + config.GRPC.Port, ok = os.LookupEnv("GRPC_PORT") + if !ok || config.GRPC.Port == "" { + return ErrGRPCPortNotSet + } + return nil +} + +func (config *Config) readGrpcGatewayPort() error { + var ok bool + config.GRPC.GatewayPort, ok = os.LookupEnv("GRPC_GATEWAY_PORT") + if !ok || config.GRPC.GatewayPort == "" { + return ErrGRPCGatewayPortNotSet + } + return nil +} + +func (config *Config) readPGHost() error { + var ok bool + config.PG.Host, ok = os.LookupEnv("POSTGRES_HOST") + if !ok || config.PG.Host == "" { + return ErrPostgresHostNotSet + } + return nil +} + +func (config *Config) readPGPort() error { + var ok bool + config.PG.Port, ok = os.LookupEnv("POSTGRES_PORT") + if !ok || config.PG.Port == "" { + return ErrPostgresPortNotSet + } + return nil +} + +func (config *Config) readPGUser() error { + var ok bool + config.PG.User, ok = os.LookupEnv("POSTGRES_USER") + if !ok || config.PG.User == "" { + return ErrPostgresUserNotSet + } + return nil +} + +func (config *Config) readPGDB() error { + var ok bool + config.PG.DB, ok = os.LookupEnv("POSTGRES_DB") + if !ok || config.PG.DB == "" { + return ErrPostgresDBNotSet + } + return nil +} + +func (config *Config) readPGPassword() error { + var ok bool + config.PG.Password, ok = os.LookupEnv("POSTGRES_PASSWORD") + if !ok || config.PG.Password == "" { + return ErrPostgresPasswordNotSet + } + return nil +} + +func (config *Config) readPGMaxConn() error { + var ok bool + config.PG.MaxConn, ok = os.LookupEnv("POSTGRES_MAX_CONN") + if !ok || config.PG.MaxConn == "" { + return ErrPostgresMaxConnNotSet + } + return nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..63050ed --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestConfigSuccess(t *testing.T) { + t.Run("Success", func(t *testing.T) { + setup(t) + cfg, err := New() + require.NoError(t, err) + require.Equal(t, "8081", cfg.GRPC.Port) + require.Equal(t, "8080", cfg.GRPC.GatewayPort) + require.Equal(t, "localhost", cfg.PG.Host) + require.Equal(t, "5432", cfg.PG.Port) + require.Equal(t, "go", cfg.PG.Password) + require.Equal(t, "10", cfg.PG.MaxConn) + require.Equal(t, "nikongo", cfg.PG.User) + require.Equal(t, "godb", cfg.PG.DB) + + }) +} + +func TestConfigFailures(t *testing.T) { + tests := []struct { + name string + envVar string + error error + }{ + { + + "GRPC_PORT not set", + "GRPC_PORT", + ErrGRPCPortNotSet, + }, + { + "GRPC_GATEWAY_PORT not set", + "GRPC_GATEWAY_PORT", + ErrGRPCGatewayPortNotSet, + }, + { + "POSTGRES_HOST not set", + "POSTGRES_HOST", + ErrPostgresHostNotSet, + }, + { + "POSTGRES_PORT not set", + "POSTGRES_PORT", + ErrPostgresPortNotSet, + }, + { + "POSTGRES_DB not set", + "POSTGRES_DB", + ErrPostgresDBNotSet, + }, + { + "POSTGRES_USER not set", + "POSTGRES_USER", + ErrPostgresUserNotSet, + }, + { + "POSTGRES_PASSWORD not set", + "POSTGRES_PASSWORD", + ErrPostgresPasswordNotSet, + }, + { + "POSTGRES_MAX_CONN not set", + "POSTGRES_MAX_CONN", + ErrPostgresMaxConnNotSet, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup(t) + t.Setenv(test.envVar, "") + _, err := New() + require.ErrorIs(t, err, test.error) + }) + } +} + +func setup(t *testing.T) { + t.Setenv("GRPC_PORT", "8081") + t.Setenv("GRPC_GATEWAY_PORT", "8080") + t.Setenv("POSTGRES_HOST", "localhost") + t.Setenv("POSTGRES_PORT", "5432") + t.Setenv("POSTGRES_DB", "godb") + t.Setenv("POSTGRES_USER", "nikongo") + t.Setenv("POSTGRES_PASSWORD", "go") + t.Setenv("POSTGRES_MAX_CONN", "10") +} diff --git a/db/migrations/001_create_author_table.sql b/db/migrations/001_create_author_table.sql new file mode 100644 index 0000000..e6f9543 --- /dev/null +++ b/db/migrations/001_create_author_table.sql @@ -0,0 +1,32 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE author +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_author_timestamp() RETURNS TRIGGER AS +$$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + + +CREATE OR REPLACE TRIGGER trigger_update_author_timestamp + BEFORE UPDATE + ON author + FOR EACH ROW +EXECUTE FUNCTION update_author_timestamp(); + + +-- +goose Down +DROP TABLE author; +DROP FUNCTION update_author_timestamp; \ No newline at end of file diff --git a/db/migrations/002_create_author_name_index.sql b/db/migrations/002_create_author_name_index.sql new file mode 100644 index 0000000..0fc4b9c --- /dev/null +++ b/db/migrations/002_create_author_name_index.sql @@ -0,0 +1,5 @@ +-- +goose Up +CREATE INDEX index_author_name ON author(name); + +-- +goose Down +DROP INDEX index_author_name; \ No newline at end of file diff --git a/db/migrations/003_create_book_table.sql b/db/migrations/003_create_book_table.sql new file mode 100644 index 0000000..5f1bd34 --- /dev/null +++ b/db/migrations/003_create_book_table.sql @@ -0,0 +1,28 @@ +-- +goose Up +CREATE TABLE book +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_book_timestamp() RETURNS TRIGGER AS +$$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE OR REPLACE TRIGGER trigger_update_book_timestamp + BEFORE UPDATE + ON book + FOR EACH ROW +EXECUTE FUNCTION update_book_timestamp(); + +-- +goose Down +DROP TABLE book; +DROP FUNCTION update_book_timestamp; \ No newline at end of file diff --git a/db/migrations/004_create_book_name_index.sql b/db/migrations/004_create_book_name_index.sql new file mode 100644 index 0000000..b21130d --- /dev/null +++ b/db/migrations/004_create_book_name_index.sql @@ -0,0 +1,5 @@ +-- +goose Up +CREATE INDEX idx_book_name ON book(name); + +-- +goose Down +DROP INDEX idx_book_name; \ No newline at end of file diff --git a/db/migrations/005_create_author_book_table.sql b/db/migrations/005_create_author_book_table.sql new file mode 100644 index 0000000..dd87b58 --- /dev/null +++ b/db/migrations/005_create_author_book_table.sql @@ -0,0 +1,10 @@ +-- +goose Up +CREATE TABLE author_book +( + author_id UUID REFERENCES author(id) ON DELETE CASCADE, + book_id UUID REFERENCES book(id) ON DELETE CASCADE, + PRIMARY KEY (author_id, book_id) +); + +-- +goose Down +DROP TABLE author_book; \ No newline at end of file diff --git a/db/migrations/006_create_book_id_index.sql b/db/migrations/006_create_book_id_index.sql new file mode 100644 index 0000000..7b96b28 --- /dev/null +++ b/db/migrations/006_create_book_id_index.sql @@ -0,0 +1,5 @@ +-- +goose Up +CREATE INDEX index_author_book_book_id ON author_book(book_id); + +-- +goose Down +DROP INDEX index_author_book_book_id; \ No newline at end of file diff --git a/.golangci.yaml b/golangci.yaml similarity index 100% rename from .golangci.yaml rename to golangci.yaml diff --git a/internal/app/app.go b/internal/app/app.go index 9be7ce9..9517702 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,13 +1,88 @@ package app import ( + "context" + "net" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/jackc/pgx/v5/pgxpool" "github.com/project/library/config" + "github.com/project/library/db" + generated "github.com/project/library/generated/api/library" + "github.com/project/library/internal/controller" + "github.com/project/library/internal/usecase/library" + "github.com/project/library/internal/usecase/repository" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/reflection" ) func Run(logger *zap.Logger, cfg *config.Config) { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + dbPool, err := pgxpool.New(ctx, cfg.PG.URL) + + if err != nil { + logger.Error("can not create pgxpool", zap.Error(err)) + } + + defer dbPool.Close() + db.SetupPostgres(dbPool, logger) + + postgres := repository.New(dbPool, logger) + useCases := library.New(logger, postgres, postgres) + + ctrl := controller.New(logger, useCases, useCases) + + go runRest(ctx, cfg, logger) + go runGrpc(cfg, logger, ctrl) + + <-ctx.Done() } -func runRest() {} +func runRest(ctx context.Context, cfg *config.Config, logger *zap.Logger) { + mux := runtime.NewServeMux() + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + address := "localhost:" + cfg.GRPC.Port + err := generated.RegisterLibraryHandlerFromEndpoint(ctx, mux, address, opts) + + if err != nil { + logger.Error("can not register grpc gateway", zap.Error(err)) + os.Exit(-1) + } -func runGrpc() {} + gatewayPort := ":" + cfg.GatewayPort + logger.Info("gateway listening at port", zap.String("port", gatewayPort)) + + if err = http.ListenAndServe(gatewayPort, mux); err != nil { + logger.Error("gateway listen error", zap.Error(err)) + } +} + +func runGrpc(cfg *config.Config, logger *zap.Logger, libraryService generated.LibraryServer) { + port := ":" + cfg.GRPC.Port + lis, err := net.Listen("tcp", port) + + if err != nil { + logger.Error("can not open tcp socket", zap.Error(err)) + os.Exit(-1) + } + + s := grpc.NewServer() + reflection.Register(s) + + generated.RegisterLibraryServer(s, libraryService) + + logger.Info("grpc server listening at port", zap.String("port", port)) + + if err = s.Serve(lis); err != nil { + logger.Error("grpc server listen error", zap.Error(err)) + } +} diff --git a/internal/controller/add_book.go b/internal/controller/add_book.go index b0b429f..c6e1a77 100644 --- a/internal/controller/add_book.go +++ b/internal/controller/add_book.go @@ -1 +1,40 @@ package controller + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) AddBook(ctx context.Context, request *library.AddBookRequest) (*library.AddBookResponse, error) { + impl.logger.Info("AddBook controller: started") + defer impl.logger.Info("AddBook controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("AddBook controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + book, err := impl.booksUseCase.AddBook(ctx, request.GetName(), impl.stringsToUUIDs(request.GetAuthorIds())) + + impl.logger.Info("AddBook controller: TIME " + book.CreatedAt.String()) + if err != nil { + impl.logger.Error("AddBook controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + impl.logger.Info("AddBook controller: TIME " + book.CreatedAt.String()) + + return &library.AddBookResponse{ + Book: &library.Book{ + Id: book.ID.String(), + Name: book.Name, + AuthorId: book.AuthorIDs.Strings(), + CreatedAt: timestamppb.New(book.CreatedAt), + UpdatedAt: timestamppb.New(book.UpdatedAt), + }, + }, nil +} diff --git a/internal/controller/change_author_info.go b/internal/controller/change_author_info.go index b0b429f..459c9c1 100644 --- a/internal/controller/change_author_info.go +++ b/internal/controller/change_author_info.go @@ -1 +1,29 @@ package controller + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) ChangeAuthorInfo(ctx context.Context, request *library.ChangeAuthorInfoRequest) (*library.ChangeAuthorInfoResponse, error) { + impl.logger.Info("ChangeAuthorInfo controller: started") + defer impl.logger.Info("ChangeAuthorInfo controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("ChangeAuthorInfo controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + authorID := uuid.Must(uuid.Parse(request.GetId())) + _, err := impl.authorsUseCase.UpdateAuthor(ctx, authorID, request.GetName()) + if err != nil { + impl.logger.Error("ChangeAuthorInfo controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.ChangeAuthorInfoResponse{}, nil +} diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go new file mode 100644 index 0000000..8ac33c8 --- /dev/null +++ b/internal/controller/controller_test.go @@ -0,0 +1,660 @@ +package controller + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "github.com/project/library/internal/entity" + custommocks "github.com/project/library/mocks-custom" + generatedmocks "github.com/project/library/mocks-generated" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestController_AddBook(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + basicBookRequest := library.AddBookRequest{ + Name: basicBook.Name, + AuthorIds: basicBook.AuthorIDs.Strings(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().AddBook(ctx, basicBook.Name, basicBook.AuthorIDs). + Return(basicBook, nil) + + result, err := controllerSetup.service.AddBook(ctx, &basicBookRequest) + require.Equal(t, result.GetBook().GetId(), basicBook.ID.String()) + require.Equal(t, result.GetBook().GetName(), basicBook.Name) + require.NoError(t, err) + }) + + t.Run("book already exist", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().AddBook(ctx, basicBook.Name, basicBook.AuthorIDs). + Return(nilBook, entity.ErrBookAlreadyExists) + + result, err := controllerSetup.service.AddBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.AlreadyExists, entity.ErrBookAlreadyExists.Error())) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().AddBook(ctx, basicBook.Name, basicBook.AuthorIDs). + Return(nilBook, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.AddBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.AddBook(ctx, &library.AddBookRequest{ + Name: basicBook.Name, + AuthorIds: []string{"invalid-uuid"}, + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_GetBook(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + basicBookRequest := library.GetBookInfoRequest{ + Id: basicBook.ID.String(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().GetBook(ctx, basicBook.ID). + Return(basicBook, nil) + + result, err := controllerSetup.service.GetBookInfo(ctx, &basicBookRequest) + require.Equal(t, result.GetBook().GetId(), basicBook.ID.String()) + require.Equal(t, result.GetBook().GetName(), basicBook.Name) + require.NoError(t, err) + }) + + t.Run("book not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().GetBook(ctx, basicBook.ID). + Return(nilBook, entity.ErrBookNotFound) + + result, err := controllerSetup.service.GetBookInfo(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrBookNotFound.Error())) + }) + + t.Run("book invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.GetBookInfo(ctx, &library.GetBookInfoRequest{ + Id: "not uuid", + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_UpdateBook(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + updatedBasicBook := entity.Book{ + ID: basicBook.ID, + Name: basicBook.Name + " update", + AuthorIDs: []uuid.UUID{uuid.Nil, uuid.Max}, + } + + basicBookRequest := library.UpdateBookRequest{ + Id: updatedBasicBook.ID.String(), + Name: updatedBasicBook.Name, + AuthorIds: updatedBasicBook.AuthorIDs.Strings(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().UpdateBook(ctx, updatedBasicBook.ID, updatedBasicBook.Name, updatedBasicBook.AuthorIDs). + Return(updatedBasicBook, nil) + + result, err := controllerSetup.service.UpdateBook(ctx, &basicBookRequest) + require.NotNil(t, result) + require.NoError(t, err) + }) + + t.Run("book not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().UpdateBook(ctx, updatedBasicBook.ID, updatedBasicBook.Name, updatedBasicBook.AuthorIDs). + Return(nilBook, entity.ErrBookNotFound) + + result, err := controllerSetup.service.UpdateBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrBookNotFound.Error())) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().UpdateBook(ctx, updatedBasicBook.ID, updatedBasicBook.Name, updatedBasicBook.AuthorIDs). + Return(nilBook, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.UpdateBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("book invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.UpdateBook(ctx, &library.UpdateBookRequest{ + Id: "not uuid", + Name: basicBook.Name, + AuthorIds: []string{}, + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.UpdateBook(ctx, &library.UpdateBookRequest{ + Id: basicBook.ID.String(), + Name: basicBook.Name, + AuthorIds: []string{"not uuid"}, + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_GetAuthorBooks(t *testing.T) { + vasya := uuid.Nil + + book1 := entity.Book{ID: uuid.Nil, Name: "Book 1", AuthorIDs: []uuid.UUID{vasya}} + book2 := entity.Book{ID: uuid.Max, Name: "Book 2", AuthorIDs: []uuid.UUID{vasya}} + basicRequest := library.GetAuthorBooksRequest{ + AuthorId: vasya.String(), + } + + t.Run("success", func(t *testing.T) { + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + + controllerSetup := createControllerSetup(controller) + + controllerSetup.booksUseCaseMock.EXPECT().GetAuthorBooks(gomock.Any(), vasya). + Return([]entity.Book{book1, book2}, nil) + + streamMock := custommocks.NewMockLibraryGetAuthorBooksServer(nil, nil) + + err := controllerSetup.service.GetAuthorBooks(&basicRequest, streamMock) + require.NoError(t, err) + require.Equal(t, len(streamMock.SentBooks), 2) + }) + + t.Run("stream send error", func(t *testing.T) { + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + + controllerSetup := createControllerSetup(controller) + + controllerSetup.booksUseCaseMock.EXPECT().GetAuthorBooks(gomock.Any(), vasya). + Return([]entity.Book{book1, book2}, nil) + + expectedErr := errors.New("send error") + streamMock := custommocks.NewMockLibraryGetAuthorBooksServer(nil, expectedErr) + + err := controllerSetup.service.GetAuthorBooks(&basicRequest, streamMock) + require.ErrorIs(t, expectedErr, err) + require.Equal(t, len(streamMock.SentBooks), 0) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + + controllerSetup := createControllerSetup(controller) + streamMock := custommocks.NewMockLibraryGetAuthorBooksServer(nil, nil) + err := controllerSetup.service.GetAuthorBooks(&library.GetAuthorBooksRequest{ + AuthorId: "not-uuid", + }, streamMock) + require.Equal(t, len(streamMock.SentBooks), 0) + require.Error(t, err) + }) +} + +func TestController_AddAuthor(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + basicAuthorRequest := library.RegisterAuthorRequest{ + Name: basicAuthor.Name, + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().AddAuthor(ctx, basicAuthor.Name). + Return(basicAuthor, nil) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &basicAuthorRequest) + require.Equal(t, result, &library.RegisterAuthorResponse{ + Id: basicAuthor.ID.String(), + }) + require.NoError(t, err) + }) + + t.Run("author already exist", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().AddAuthor(ctx, basicAuthor.Name). + Return(nilAuthor, entity.ErrAuthorAlreadyExists) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &basicAuthorRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.AlreadyExists, entity.ErrAuthorAlreadyExists.Error())) + }) + + t.Run("author invalid name (unsupported symbol)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &library.RegisterAuthorRequest{ + Name: "@", + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid name (invalid length)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &library.RegisterAuthorRequest{ + Name: "", + }) + require.Nil(t, result) + require.Error(t, err) + + result, err = controllerSetup.service.RegisterAuthor(ctx, &library.RegisterAuthorRequest{ + Name: strings.Repeat("a", 1000), + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_GetAuthor(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + basicAuthorRequest := library.GetAuthorInfoRequest{ + Id: basicAuthor.ID.String(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().GetAuthor(ctx, basicAuthor.ID). + Return(basicAuthor, nil) + + result, err := controllerSetup.service.GetAuthorInfo(ctx, &basicAuthorRequest) + require.Equal(t, result, &library.GetAuthorInfoResponse{ + Id: basicAuthor.ID.String(), + Name: basicAuthor.Name, + }) + require.NoError(t, err) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().GetAuthor(ctx, basicAuthor.ID). + Return(nilAuthor, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.GetAuthorInfo(ctx, &basicAuthorRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.GetAuthorInfo(ctx, &library.GetAuthorInfoRequest{ + Id: "not uuid", + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_UpdateAuthor(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + updatedBasicAuthor := entity.Author{ + ID: basicAuthor.ID, + Name: basicAuthor.Name + " update", + } + + basicAuthorRequest := library.ChangeAuthorInfoRequest{ + Id: updatedBasicAuthor.ID.String(), + Name: updatedBasicAuthor.Name, + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().UpdateAuthor(ctx, updatedBasicAuthor.ID, updatedBasicAuthor.Name). + Return(updatedBasicAuthor, nil) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &basicAuthorRequest) + require.NotNil(t, result) + require.NoError(t, err) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().UpdateAuthor(ctx, updatedBasicAuthor.ID, updatedBasicAuthor.Name). + Return(nilAuthor, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &basicAuthorRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &library.ChangeAuthorInfoRequest{ + Id: "not uuid", + Name: "author", + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid name (unsupported symbol)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &library.ChangeAuthorInfoRequest{ + Id: uuid.Nil.String(), + Name: "@", + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid name (unsupported length)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &library.ChangeAuthorInfoRequest{ + Id: uuid.Nil.String(), + Name: strings.Repeat("a", 1000), + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +type controllerSetup struct { + booksUseCaseMock generatedmocks.MockBooksUseCase + authorsUseCaseMock generatedmocks.MockAuthorsUseCase + service library.LibraryServer +} + +func createControllerSetup(controller *gomock.Controller) *controllerSetup { + booksUseCaseMock := generatedmocks.NewMockBooksUseCase(controller) + authorsUseCaseMock := generatedmocks.NewMockAuthorsUseCase(controller) + logger := zap.NewNop() + + return &controllerSetup{ + booksUseCaseMock: *booksUseCaseMock, + authorsUseCaseMock: *authorsUseCaseMock, + service: New(logger, booksUseCaseMock, authorsUseCaseMock), + } +} diff --git a/internal/controller/get_author_books.go b/internal/controller/get_author_books.go index b0b429f..e8c4d26 100644 --- a/internal/controller/get_author_books.go +++ b/internal/controller/get_author_books.go @@ -1 +1,40 @@ package controller + +import ( + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (impl *implementation) GetAuthorBooks(request *library.GetAuthorBooksRequest, stream library.Library_GetAuthorBooksServer) error { + impl.logger.Info("GetAuthorBooks controller: started") + defer impl.logger.Info("GetAuthorBooks controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("GetAuthorBooks controller: invalid argument") + return status.Error(codes.InvalidArgument, err.Error()) + } + + authorID := uuid.Must(uuid.Parse(request.GetAuthorId())) + books, err := impl.booksUseCase.GetAuthorBooks(stream.Context(), authorID) + if err != nil { + return status.Error(codes.Internal, err.Error()) + } + + for _, book := range books { + if err := stream.Send(&library.Book{ + Id: book.ID.String(), + Name: book.Name, + AuthorId: book.AuthorIDs.Strings(), + CreatedAt: timestamppb.New(book.CreatedAt), + UpdatedAt: timestamppb.New(book.UpdatedAt), + }); err != nil { + impl.logger.Error("GetAuthorBooks controller: failed to send book - " + err.Error()) + return err + } + } + + return nil +} diff --git a/internal/controller/get_author_info.go b/internal/controller/get_author_info.go index b0b429f..e87e4bf 100644 --- a/internal/controller/get_author_info.go +++ b/internal/controller/get_author_info.go @@ -1 +1,32 @@ package controller + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) GetAuthorInfo(ctx context.Context, request *library.GetAuthorInfoRequest) (*library.GetAuthorInfoResponse, error) { + impl.logger.Info("GetAuthorInfo controller: started") + defer impl.logger.Info("GetAuthorInfo controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("GetAuthorInfo controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + authorID := uuid.Must(uuid.Parse(request.GetId())) + author, err := impl.authorsUseCase.GetAuthor(ctx, authorID) + if err != nil { + impl.logger.Error("GetAuthorInfo controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.GetAuthorInfoResponse{ + Id: author.ID.String(), + Name: author.Name, + }, nil +} diff --git a/internal/controller/get_book_info.go b/internal/controller/get_book_info.go index b0b429f..7dac50b 100644 --- a/internal/controller/get_book_info.go +++ b/internal/controller/get_book_info.go @@ -1 +1,40 @@ package controller + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) GetBookInfo(ctx context.Context, request *library.GetBookInfoRequest) (*library.GetBookInfoResponse, error) { + impl.logger.Info("GetBookInfo controller: started") + defer impl.logger.Info("GetBookInfo controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("GetBookInfo controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + bookID := uuid.Must(uuid.Parse(request.GetId())) + book, err := impl.booksUseCase.GetBook(ctx, bookID) + if err != nil { + impl.logger.Error("GetBookInfo controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + impl.logger.Info("AddBook controller: TIME " + book.CreatedAt.String()) + return &library.GetBookInfoResponse{ + Book: &library.Book{ + Id: book.ID.String(), + Name: book.Name, + AuthorId: book.AuthorIDs.Strings(), + CreatedAt: timestamppb.New(book.CreatedAt), + UpdatedAt: timestamppb.New(book.UpdatedAt), + }, + }, nil +} diff --git a/internal/controller/register_author.go b/internal/controller/register_author.go index b0b429f..6955292 100644 --- a/internal/controller/register_author.go +++ b/internal/controller/register_author.go @@ -1 +1,29 @@ package controller + +import ( + "context" + + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) RegisterAuthor(ctx context.Context, request *library.RegisterAuthorRequest) (*library.RegisterAuthorResponse, error) { + impl.logger.Info("RegisterAuthor controller: started") + defer impl.logger.Info("RegisterAuthor controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("RegisterAuthor controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + author, err := impl.authorsUseCase.AddAuthor(ctx, request.GetName()) + if err != nil { + impl.logger.Error("RegisterAuthor controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.RegisterAuthorResponse{ + Id: author.ID.String(), + }, nil +} diff --git a/internal/controller/service.go b/internal/controller/service.go index b0b429f..421d1b4 100644 --- a/internal/controller/service.go +++ b/internal/controller/service.go @@ -1 +1,27 @@ package controller + +import ( + generated "github.com/project/library/generated/api/library" + "github.com/project/library/internal/usecase/library" + "go.uber.org/zap" +) + +var _ generated.LibraryServer = (*implementation)(nil) + +type implementation struct { + logger *zap.Logger + booksUseCase library.BooksUseCase + authorsUseCase library.AuthorsUseCase +} + +func New( + logger *zap.Logger, + booksUseCase library.BooksUseCase, + authorsUseCase library.AuthorsUseCase, +) *implementation { + return &implementation{ + logger: logger, + booksUseCase: booksUseCase, + authorsUseCase: authorsUseCase, + } +} diff --git a/internal/controller/update_book.go b/internal/controller/update_book.go index b0b429f..0e24163 100644 --- a/internal/controller/update_book.go +++ b/internal/controller/update_book.go @@ -1 +1,29 @@ package controller + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) UpdateBook(ctx context.Context, request *library.UpdateBookRequest) (*library.UpdateBookResponse, error) { + impl.logger.Info("UpdateBook controller: started") + defer impl.logger.Info("UpdateBook controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("UpdateBook controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + bookID := uuid.Must(uuid.Parse(request.GetId())) + _, err := impl.booksUseCase.UpdateBook(ctx, bookID, request.GetName(), impl.stringsToUUIDs(request.GetAuthorIds())) + if err != nil { + impl.logger.Error("UpdateBook controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.UpdateBookResponse{}, nil +} diff --git a/internal/controller/util.go b/internal/controller/util.go index b0b429f..3f5d9a4 100644 --- a/internal/controller/util.go +++ b/internal/controller/util.go @@ -1 +1,34 @@ package controller + +import ( + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/project/library/internal/entity" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) libraryToGrpcErr(err error) error { + switch { + case errors.Is(err, entity.ErrBookNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, entity.ErrBookAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + case errors.Is(err, entity.ErrAuthorNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, entity.ErrAuthorAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + default: + return status.Error(codes.Internal, err.Error()) + } +} + +// call ONLY after ValidateAll +func (impl *implementation) stringsToUUIDs(strings []string) uuid.UUIDs { + uuids := make([]uuid.UUID, 0, len(strings)) + for _, s := range strings { + u := uuid.Must(uuid.Parse(s)) + uuids = append(uuids, u) + } + return uuids +} diff --git a/internal/entity/author.go b/internal/entity/author.go index 9356433..af4a249 100644 --- a/internal/entity/author.go +++ b/internal/entity/author.go @@ -1 +1,17 @@ package entity + +import ( + "errors" + + "github.com/google/uuid" +) + +type Author struct { + ID uuid.UUID + Name string +} + +var ( + ErrAuthorNotFound = errors.New("Author not found") + ErrAuthorAlreadyExists = errors.New("Author already exists") +) diff --git a/internal/entity/book.go b/internal/entity/book.go index 9356433..fcdcda6 100644 --- a/internal/entity/book.go +++ b/internal/entity/book.go @@ -1 +1,21 @@ package entity + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +type Book struct { + ID uuid.UUID + Name string + AuthorIDs uuid.UUIDs + CreatedAt time.Time + UpdatedAt time.Time +} + +var ( + ErrBookNotFound = errors.New("book not found") + ErrBookAlreadyExists = errors.New("book already exists") +) diff --git a/internal/usecase/library/authors.go b/internal/usecase/library/authors.go index e196664..9161990 100644 --- a/internal/usecase/library/authors.go +++ b/internal/usecase/library/authors.go @@ -1 +1,28 @@ package library + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" +) + +func (library *libraryImpl) AddAuthor(ctx context.Context, name string) (entity.Author, error) { + library.logger.Info("AddAuthor use case: started") + defer library.logger.Info("AddAuthor use case: finished") + return library.authorsRepository.AddAuthor(ctx, entity.Author{ + Name: name, + }) +} + +func (library *libraryImpl) GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) { + library.logger.Info("GetAuthor use case: started") + defer library.logger.Info("GetAuthor use case: finished") + return library.authorsRepository.GetAuthor(ctx, authorID) +} + +func (library *libraryImpl) UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) { + library.logger.Info("UpdateAuthor use case: started") + defer library.logger.Info("GetAuthor use case: finished") + return library.authorsRepository.UpdateAuthor(ctx, authorID, name) +} diff --git a/internal/usecase/library/books.go b/internal/usecase/library/books.go index e196664..ac110a9 100644 --- a/internal/usecase/library/books.go +++ b/internal/usecase/library/books.go @@ -1 +1,39 @@ package library + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" +) + +func (library *libraryImpl) AddBook(ctx context.Context, name string, authorIDs uuid.UUIDs) (entity.Book, error) { + library.logger.Info("AddBook use case: started") + defer library.logger.Info("AddBook use case: finished") + + return library.booksRepository.AddBook(ctx, entity.Book{ + Name: name, + AuthorIDs: authorIDs, + }) +} + +func (library *libraryImpl) GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) { + library.logger.Info("GetBook use case: started") + defer library.logger.Info("GetBook use case: finished") + + return library.booksRepository.GetBook(ctx, bookID) +} + +func (library *libraryImpl) UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) { + library.logger.Info("UpdateBook use case: started") + defer library.logger.Info("UpdateBook use case: finished") + + return library.booksRepository.UpdateBook(ctx, bookID, name, authorIDs) +} + +func (library *libraryImpl) GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) { + library.logger.Info("GetAuthorBooks use case: started") + defer library.logger.Info("GetAuthorBooks use case: finished") + + return library.booksRepository.GetAuthorBooks(ctx, authorID) +} diff --git a/internal/usecase/library/interfaces.go b/internal/usecase/library/interfaces.go index e196664..04a6231 100644 --- a/internal/usecase/library/interfaces.go +++ b/internal/usecase/library/interfaces.go @@ -1 +1,46 @@ package library + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" + "github.com/project/library/internal/usecase/repository" + "go.uber.org/zap" +) + +//go:generate ../../../bin/mockgen -source=interfaces.go -destination=../../../mocks-generated/usecases_mock.go -package=mocks_generated + +type BooksUseCase interface { + AddBook(ctx context.Context, name string, authorIDs uuid.UUIDs) (entity.Book, error) + GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) + UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) + GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) +} + +type AuthorsUseCase interface { + AddAuthor(ctx context.Context, name string) (entity.Author, error) + GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) + UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) +} + +var _ BooksUseCase = (*libraryImpl)(nil) +var _ AuthorsUseCase = (*libraryImpl)(nil) + +type libraryImpl struct { + logger *zap.Logger + booksRepository repository.BooksRepository + authorsRepository repository.AuthorsRepository +} + +func New( + logger *zap.Logger, + booksRepository repository.BooksRepository, + authorsRepository repository.AuthorsRepository, +) *libraryImpl { + return &libraryImpl{ + logger: logger, + booksRepository: booksRepository, + authorsRepository: authorsRepository, + } +} diff --git a/internal/usecase/library/library_test.go b/internal/usecase/library/library_test.go new file mode 100644 index 0000000..21b9bdf --- /dev/null +++ b/internal/usecase/library/library_test.go @@ -0,0 +1,374 @@ +package library + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" + mocks "github.com/project/library/mocks-generated" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestLibrary_BooksUseCase(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + t.Run("AddBook", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expectedBook entity.Book + expectedError error + inputName string + inputAuthorIDs []uuid.UUID + }{ + { + name: "success", + expectedBook: basicBook, + expectedError: nil, + inputName: basicBook.Name, + inputAuthorIDs: basicBook.AuthorIDs, + }, + { + name: "author not found", + expectedBook: nilBook, + expectedError: entity.ErrAuthorNotFound, + inputName: basicBook.Name, + inputAuthorIDs: basicBook.AuthorIDs, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT().AddBook(ctx, gomock.Any()). + Return(test.expectedBook, test.expectedError) + + result, err := testSetup.booksUseCase.AddBook(ctx, test.inputName, test.inputAuthorIDs) + require.Equal(t, result, test.expectedBook) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("GetBook", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + book entity.Book + expectedBook entity.Book + expectedError error + }{ + { + name: "success", + book: basicBook, + expectedBook: basicBook, + expectedError: nil, + }, + { + name: "book bot found", + book: basicBook, + expectedBook: nilBook, + expectedError: entity.ErrBookNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT().GetBook(ctx, gomock.Any()). + Return(test.expectedBook, test.expectedError) + + result, err := testSetup.booksUseCase.GetBook(ctx, test.book.ID) + require.Equal(t, result, test.expectedBook) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("UpdateBook", func(t *testing.T) { + t.Parallel() + + updatedBook := entity.Book{ + ID: basicBook.ID, + Name: basicBook.Name + " update", + AuthorIDs: []uuid.UUID{uuid.Nil, uuid.Max}, + } + + tests := []struct { + name string + book entity.Book + expectedBook entity.Book + expectedError error + }{ + { + name: "success", + book: updatedBook, + expectedBook: updatedBook, + expectedError: nil, + }, + { + name: "book bot found", + book: updatedBook, + expectedBook: nilBook, + expectedError: entity.ErrBookNotFound, + }, + { + name: "author not found", + book: updatedBook, + expectedBook: nilBook, + expectedError: entity.ErrAuthorNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT().UpdateBook(ctx, test.book.ID, test.book.Name, test.book.AuthorIDs). + Return(test.expectedBook, test.expectedError) + + result, err := testSetup.booksUseCase.UpdateBook(ctx, test.book.ID, test.book.Name, test.book.AuthorIDs) + require.Equal(t, result, test.expectedBook) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("GetAuthorBooks", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + defer controller.Finish() + + vasya := uuid.Nil + + book1 := entity.Book{ID: uuid.Nil, Name: "Book 1", AuthorIDs: []uuid.UUID{vasya}} + book2 := entity.Book{ID: uuid.Max, Name: "Book 2", AuthorIDs: []uuid.UUID{vasya}} + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT(). + GetAuthorBooks(ctx, vasya). + Return([]entity.Book{book1, book2}, nil) + + result, err := testSetup.booksUseCase.GetAuthorBooks(context.Background(), vasya) + require.NoError(t, err) + require.ElementsMatch(t, []entity.Book{book1, book2}, result) + }) + + t.Run("author not found", func(t *testing.T) { + ctx := context.Background() + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT(). + GetAuthorBooks(ctx, vasya). + Return(nil, entity.ErrAuthorNotFound) + + result, err := testSetup.booksUseCase.GetAuthorBooks(context.Background(), vasya) + require.ErrorIs(t, entity.ErrAuthorNotFound, err) + require.Nil(t, result) + }) + }) +} + +func TestLibrary_AuthorsUseCase(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + t.Run("AddAuthor", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + author entity.Author + expectedAuthor entity.Author + expectedError error + }{ + { + name: "success", + author: basicAuthor, + expectedAuthor: basicAuthor, + expectedError: nil, + }, + { + name: "author already exists", + author: basicAuthor, + expectedAuthor: nilAuthor, + expectedError: entity.ErrAuthorAlreadyExists, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.authorsRepositoryMock.EXPECT().AddAuthor(ctx, gomock.Any()). + Return(test.expectedAuthor, test.expectedError) + + result, err := testSetup.authorsUseCase.AddAuthor(ctx, test.author.Name) + require.Equal(t, result, test.expectedAuthor) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("GetAuthor", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + author entity.Author + expectedAuthor entity.Author + expectedError error + }{ + { + name: "success", + author: basicAuthor, + expectedAuthor: basicAuthor, + expectedError: nil, + }, + { + name: "author not found", + author: basicAuthor, + expectedAuthor: nilAuthor, + expectedError: entity.ErrAuthorNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.authorsRepositoryMock.EXPECT().GetAuthor(ctx, test.author.ID). + Return(test.expectedAuthor, test.expectedError) + + result, err := testSetup.authorsUseCase.GetAuthor(ctx, test.author.ID) + require.Equal(t, result, test.expectedAuthor) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("UpdateAuthor", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + author entity.Author + expectedAuthor entity.Author + expectedError error + }{ + { + name: "success", + author: basicAuthor, + expectedAuthor: entity.Author{ + ID: basicAuthor.ID, + Name: basicAuthor.Name + " update", + }, + expectedError: nil, + }, + { + name: "author not found", + author: basicAuthor, + expectedError: entity.ErrAuthorNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.authorsRepositoryMock.EXPECT().UpdateAuthor(ctx, test.author.ID, test.author.Name). + Return(test.expectedAuthor, test.expectedError) + + author, err := testSetup.authorsUseCase.UpdateAuthor(ctx, test.author.ID, test.author.Name) + require.Equal(t, author, test.expectedAuthor) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) +} + +type setup struct { + booksRepositoryMock *mocks.MockBooksRepository + authorsRepositoryMock *mocks.MockAuthorsRepository + booksUseCase BooksUseCase + authorsUseCase AuthorsUseCase +} + +func createSetup(controller *gomock.Controller) setup { + authorsRepository := mocks.NewMockAuthorsRepository(controller) + booksRepository := mocks.NewMockBooksRepository(controller) + logger := zap.NewNop() + lib := New(logger, booksRepository, authorsRepository) + + return setup{ + booksRepositoryMock: booksRepository, + authorsRepositoryMock: authorsRepository, + booksUseCase: lib, + authorsUseCase: lib, + } +} diff --git a/internal/usecase/repository/inmemory.go b/internal/usecase/repository/inmemory.go deleted file mode 100644 index 50a4378..0000000 --- a/internal/usecase/repository/inmemory.go +++ /dev/null @@ -1 +0,0 @@ -package repository diff --git a/internal/usecase/repository/interfaces.go b/internal/usecase/repository/interfaces.go index 50a4378..a7493e8 100644 --- a/internal/usecase/repository/interfaces.go +++ b/internal/usecase/repository/interfaces.go @@ -1 +1,23 @@ package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" +) + +//go:generate ../../../bin/mockgen -source=interfaces.go -destination=../../../mocks-generated/repositories_mock.go -package=mocks_generated + +type BooksRepository interface { + AddBook(ctx context.Context, book entity.Book) (entity.Book, error) + GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) + UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) + GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) +} + +type AuthorsRepository interface { + AddAuthor(ctx context.Context, author entity.Author) (entity.Author, error) + GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) + UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) +} diff --git a/internal/usecase/repository/postgres.go b/internal/usecase/repository/postgres.go new file mode 100644 index 0000000..74e589c --- /dev/null +++ b/internal/usecase/repository/postgres.go @@ -0,0 +1,319 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "go.uber.org/zap" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/project/library/internal/entity" +) + +var _ BooksRepository = (*postgresRepository)(nil) +var _ AuthorsRepository = (*postgresRepository)(nil) + +type postgresRepository struct { + db *pgxpool.Pool + logger *zap.Logger +} + +func New(db *pgxpool.Pool, logger *zap.Logger) *postgresRepository { + return &postgresRepository{ + db: db, + logger: logger, + } +} + +func (repo *postgresRepository) AddAuthor(ctx context.Context, author entity.Author) (entity.Author, error) { + repo.logger.Info("AddAuthor repo: started") + defer repo.logger.Info("AddAuthor repo: finished") + + return withTransaction(ctx, repo.db, repo.logger, func(tx pgx.Tx) (entity.Author, error) { + const query = ` + INSERT INTO author (name) + VALUES ($1) + RETURNING id + ` + err := tx.QueryRow(ctx, query, author.Name).Scan(&author.ID) + if err != nil { + return entity.Author{}, fmt.Errorf("add author query failed: %w", err) + } + + return author, nil + }) +} + +func (repo *postgresRepository) GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) { + repo.logger.Info("GetAuthor repo: started") + defer repo.logger.Info("GetAuthor repo: finished") + const query = ` + SELECT id, name + FROM author + WHERE id = $1 + ` + var author entity.Author + err := repo.db.QueryRow(ctx, query, authorID).Scan(&author.ID, &author.Name) + + if errors.Is(err, pgx.ErrNoRows) { + return entity.Author{}, entity.ErrAuthorNotFound + } + + if err != nil { + return entity.Author{}, fmt.Errorf("get author query failed: %w", err) + } + + return author, nil +} + +func (repo *postgresRepository) UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) { + repo.logger.Info("UpdateAuthor repo: started") + defer repo.logger.Info("UpdateAuthor repo: finished") + return withTransaction(ctx, repo.db, repo.logger, func(tx pgx.Tx) (entity.Author, error) { + const query = ` + UPDATE author + SET name = $1 + WHERE id = $2 + RETURNING id, name + ` + var author entity.Author + err := tx.QueryRow(ctx, query, name, authorID).Scan(&author.ID, &author.Name) + if errors.Is(err, pgx.ErrNoRows) { + return entity.Author{}, entity.ErrAuthorNotFound + } + + if err != nil { + return entity.Author{}, fmt.Errorf("update author query failed: %w", err) + } + + return author, nil + }) +} + +func (repo *postgresRepository) AddBook(ctx context.Context, book entity.Book) (entity.Book, error) { + repo.logger.Info("AddBook repo: started") + defer repo.logger.Info("AddBook repo: finished") + return withTransaction(ctx, repo.db, repo.logger, func(tx pgx.Tx) (entity.Book, error) { + const queryAddBook = ` + INSERT INTO book (name) + VALUES ($1) + RETURNING id, name, created_at , updated_at + ` + + var addedBook entity.Book + err := tx.QueryRow(ctx, queryAddBook, book.Name).Scan(&addedBook.ID, &addedBook.Name, &addedBook.CreatedAt, &addedBook.UpdatedAt) + + if err != nil { + return entity.Book{}, fmt.Errorf("add book query failed: %w", err) + } + + if err := insertAuthorsBatch(ctx, tx, addedBook.ID, book.AuthorIDs); err != nil { + return entity.Book{}, fmt.Errorf("add book's authors query failed: %w", err) + } + + addedBook.AuthorIDs = book.AuthorIDs + return addedBook, nil + }) +} + +func (repo *postgresRepository) GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) { + repo.logger.Info("GetBook repo: started") + defer repo.logger.Info("GetBook repo: finished") + const query = ` + SELECT + id, + name, + created_at, + updated_at, + ARRAY( + SELECT author_id + FROM author_book + WHERE book_id = $1 + ) AS author_ids + FROM book + WHERE id = $1 + ` + + var book entity.Book + err := repo.db.QueryRow(ctx, query, bookID).Scan( + &book.ID, + &book.Name, + &book.CreatedAt, + &book.UpdatedAt, + &book.AuthorIDs, + ) + + if errors.Is(err, pgx.ErrNoRows) { + return entity.Book{}, entity.ErrBookNotFound + } + if err != nil { + return entity.Book{}, fmt.Errorf("get book query failed: %w", err) + } + + return book, nil +} + +func (repo *postgresRepository) UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) { + repo.logger.Info("UpdateBook repo: started") + defer repo.logger.Info("UpdateBook repo: finished") + return withTransaction(ctx, repo.db, repo.logger, func(tx pgx.Tx) (entity.Book, error) { + const queryUpdateBook = ` + UPDATE book + SET name = $1 + WHERE id = $2 + RETURNING id, name, created_at, updated_at; + ` + + var book entity.Book + err := tx.QueryRow(ctx, queryUpdateBook, name, bookID).Scan(&book.ID, &book.Name, &book.CreatedAt, &book.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return entity.Book{}, entity.ErrBookNotFound + } + if err != nil { + return entity.Book{}, fmt.Errorf("update book query failed: %w", err) + } + + const queryDeleteBookAuthors = ` + DELETE FROM author_book + WHERE book_id = $1; + ` + + _, err = tx.Exec(ctx, queryDeleteBookAuthors, bookID) + if err != nil { + return entity.Book{}, fmt.Errorf("delete previous book's author query failed: %w", err) + } + + if err := insertAuthorsBatch(ctx, tx, bookID, authorIDs); err != nil { + return entity.Book{}, fmt.Errorf("add book's authors query failed: %w", err) + } + + book.AuthorIDs = authorIDs + return book, nil + }) +} + +func (repo *postgresRepository) GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) { + repo.logger.Info("GetAuthorBooks repo: started") + defer repo.logger.Info("GetAuthorBooks repo: finished") + const query = ` + SELECT + book.id, + book.name, + book.created_at, + book.updated_at, + ARRAY( + SELECT author_book.author_id + FROM author_book + WHERE author_book.book_id = book.id + ) AS author_ids + FROM book + INNER JOIN author_book ON book.id = author_book.book_id + WHERE author_book.author_id = $1 + ` + + rows, err := repo.db.Query(ctx, query, authorID) + if err != nil { + return nil, fmt.Errorf("failed to query author books: %w", err) + } + defer rows.Close() + + var books []entity.Book + for rows.Next() { + var book entity.Book + var authorIDs []uuid.UUID + + err := rows.Scan( + &book.ID, + &book.Name, + &book.CreatedAt, + &book.UpdatedAt, + &authorIDs, + ) + + if err != nil { + return nil, fmt.Errorf("failed to scan book row: %w", err) + } + + book.AuthorIDs = authorIDs + books = append(books, book) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + return books, nil +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23503" + } + return false +} + +func withTransaction[T any](ctx context.Context, db *pgxpool.Pool, logger *zap.Logger, fn func(pgx.Tx) (T, error)) (T, error) { + logger.Info("Transaction started") + defer logger.Info("Transaction finished") + var zero T + tx, err := db.Begin(ctx) + if err != nil { + return zero, err + } + defer func(tx pgx.Tx, ctx context.Context) { + err := tx.Rollback(ctx) + if err != nil { + logger.Error("failed to rollback transaction", zap.Error(err)) + } + logger.Info("Transaction rolled back") + }(tx, ctx) + + result, err := fn(tx) + if err != nil { + return zero, err + } + + if err = tx.Commit(ctx); err != nil { + logger.Info("Not commited", zap.Error(err)) + return zero, err + } + + logger.Info("Commited") + + return result, nil +} + +func insertAuthorsBatch(ctx context.Context, tx pgx.Tx, bookID uuid.UUID, authorIDs []uuid.UUID) error { + batch := &pgx.Batch{} + for _, authorID := range authorIDs { + batch.Queue( + "INSERT INTO author_book (author_id, book_id) VALUES ($1, $2)", + authorID, bookID, + ) + } + + results := tx.SendBatch(ctx, batch) + + for range authorIDs { + _, err := results.Exec() + + if isForeignKeyViolation(err) { + return entity.ErrAuthorNotFound + } + if err != nil { + return fmt.Errorf("batch insert failed: %w", err) + } + } + + if err := results.Close(); err != nil { + return fmt.Errorf("insert batch closing failed: %w", err) + } + + return nil +} diff --git a/mocks-custom/stream_mock.go b/mocks-custom/stream_mock.go new file mode 100644 index 0000000..579d088 --- /dev/null +++ b/mocks-custom/stream_mock.go @@ -0,0 +1,81 @@ +package mocks_custom + +import ( + context "context" + "sync" + + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/metadata" +) + +type MockLibraryGetAuthorBooksServer struct { + SentBooks []*library.Book + SendError error + CancelCtx context.CancelFunc + mu sync.Mutex +} + +func NewMockLibraryGetAuthorBooksServer(cancel context.CancelFunc, sendError error) *MockLibraryGetAuthorBooksServer { + return &MockLibraryGetAuthorBooksServer{ + SendError: sendError, + CancelCtx: cancel, + } +} + +// RecvMsg implements library.Library_GetAuthorBooksServer. +func (*MockLibraryGetAuthorBooksServer) RecvMsg(_ any) error { + panic("unimplemented") +} + +// SendHeader implements library.Library_GetAuthorBooksServer. +func (m *MockLibraryGetAuthorBooksServer) SendHeader(metadata.MD) error { + panic("unimplemented") +} + +// SendMsg implements library.Library_GetAuthorBooksServer. +func (*MockLibraryGetAuthorBooksServer) SendMsg(_ any) error { + panic("unimplemented") +} + +// SetHeader implements library.Library_GetAuthorBooksServer. +func (m *MockLibraryGetAuthorBooksServer) SetHeader(metadata.MD) error { + panic("unimplemented") +} + +// SetTrailer implements library.Library_GetAuthorBooksServer. +func (m *MockLibraryGetAuthorBooksServer) SetTrailer(metadata.MD) { + panic("unimplemented") +} + +// Send сохраняет отправленные книги и возвращает заданную ошибку +func (m *MockLibraryGetAuthorBooksServer) Send(book *library.Book) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.SendError == nil { + m.SentBooks = append(m.SentBooks, book) + } + return m.SendError +} + +// Context возвращает контекст мока +func (m *MockLibraryGetAuthorBooksServer) Context() context.Context { + return context.Background() +} + +// Reset очищает состояние мока +func (m *MockLibraryGetAuthorBooksServer) Reset() { + panic("unimplemented") +} + +// WithCancel добавляет возможность отмены контекста +func (m *MockLibraryGetAuthorBooksServer) WithCancel() *MockLibraryGetAuthorBooksServer { + panic("unimplemented") +} + +// Cancel вызывает отмену контекста +func (m *MockLibraryGetAuthorBooksServer) Cancel() { + if m.CancelCtx != nil { + m.CancelCtx() + } +}