mirror of
https://github.com/3ybactuk/marketplace-go-service-project.git
synced 2025-10-30 05:53:45 +03:00
[hw-3] loms service
This commit is contained in:
22
loms/Dockerfile
Normal file
22
loms/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM golang:1.23.9-alpine as builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY loms/go.mod go.mod
|
||||
COPY loms/go.sum go.sum
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
WORKDIR loms
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server/main.go
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder server /bin/server
|
||||
COPY loms/configs/values_local.yaml /bin/config/values_local.yaml
|
||||
|
||||
ENV CONFIG_FILE=/bin/config/values_local.yaml
|
||||
|
||||
ENTRYPOINT ["/bin/server"]
|
||||
18
loms/cmd/server/main.go
Normal file
18
loms/cmd/server/main.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"route256/loms/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
srv, err := app.NewApp(os.Getenv("CONFIG_FILE"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
service:
|
||||
host: localhost
|
||||
host: "[::]"
|
||||
grpc_port: 8083
|
||||
http_port: 8084
|
||||
log_level: trace
|
||||
|
||||
jaeger:
|
||||
host: localhost
|
||||
|
||||
24
loms/go.mod
24
loms/go.mod
@@ -1,3 +1,27 @@
|
||||
module route256/loms
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
github.com/gojuno/minimock/v3 v3.4.5
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
google.golang.org/grpc v1.73.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
||||
)
|
||||
|
||||
72
loms/go.sum
Normal file
72
loms/go.sum
Normal file
@@ -0,0 +1,72 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gojuno/minimock/v3 v3.4.5 h1:Jcb0tEYZvVlQNtAAYpg3jCOoSwss2c1/rNugYTzj304=
|
||||
github.com/gojuno/minimock/v3 v3.4.5/go.mod h1:o9F8i2IT8v3yirA7mmdpNGzh1WNesm6iQakMtQV6KiE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
119
loms/internal/app/app.go
Normal file
119
loms/internal/app/app.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
"route256/loms/internal/app/server"
|
||||
ordersRepository "route256/loms/internal/domain/repository/orders"
|
||||
stocksRepository "route256/loms/internal/domain/repository/stocks"
|
||||
"route256/loms/internal/domain/service"
|
||||
"route256/loms/internal/infra/config"
|
||||
mw "route256/loms/internal/infra/grpc/middleware"
|
||||
|
||||
pb "route256/pkg/api/loms/v1"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config *config.Config
|
||||
controller *server.Server
|
||||
}
|
||||
|
||||
func NewApp(configPath string) (*App, error) {
|
||||
c, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load config: %w", err)
|
||||
}
|
||||
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
|
||||
if c.Service.LogLevel != "" {
|
||||
level, logErr := zerolog.ParseLevel(c.Service.LogLevel)
|
||||
if logErr != nil {
|
||||
return nil, fmt.Errorf("unknown log level `%s` provided: %w", c.Service.LogLevel, logErr)
|
||||
}
|
||||
|
||||
zerolog.SetGlobalLevel(level)
|
||||
}
|
||||
|
||||
log.WithLevel(zerolog.GlobalLevel()).Msgf("using logging level=`%s`", zerolog.GlobalLevel().String())
|
||||
|
||||
stockRepo, err := stocksRepository.NewInMemoryRepository(100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stocksRepository.NewInMemoryRepository: %w", err)
|
||||
}
|
||||
|
||||
orderRepo := ordersRepository.NewInMemoryRepository(100)
|
||||
service := service.NewLomsService(orderRepo, stockRepo)
|
||||
controller := server.NewServer(service)
|
||||
|
||||
app := &App{
|
||||
config: c,
|
||||
controller: controller,
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (app *App) ListenAndServe() error {
|
||||
grpcAddr := fmt.Sprintf("%s:%s", app.config.Service.Host, app.config.Service.GRPCPort)
|
||||
l, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(
|
||||
mw.Logging,
|
||||
mw.Validate,
|
||||
),
|
||||
)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
pb.RegisterLOMSServer(grpcServer, app.controller)
|
||||
|
||||
go func() {
|
||||
if err = grpcServer.Serve(l); err != nil {
|
||||
log.Fatal().Msgf("failed to serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info().Msgf("Serving grpc loms at grpc://%s", l.Addr())
|
||||
|
||||
// Setup HTTP gateway
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
grpcAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grpc.NewClient: %w", err)
|
||||
}
|
||||
|
||||
gwmux := runtime.NewServeMux()
|
||||
if err = pb.RegisterLOMSHandler(context.Background(), gwmux, conn); err != nil {
|
||||
return fmt.Errorf("pb.RegisterLOMSHandler: %w", err)
|
||||
}
|
||||
|
||||
gwServer := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", app.config.Service.Host, app.config.Service.HTTPPort),
|
||||
Handler: gwmux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
log.Info().Msgf("Serving http loms at http://%s", gwServer.Addr)
|
||||
|
||||
return gwServer.ListenAndServe()
|
||||
}
|
||||
148
loms/internal/app/server/server.go
Normal file
148
loms/internal/app/server/server.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
"route256/loms/internal/domain/entity"
|
||||
"route256/loms/internal/domain/model"
|
||||
|
||||
pb "route256/pkg/api/loms/v1"
|
||||
)
|
||||
|
||||
var _ pb.LOMSServer = (*Server)(nil)
|
||||
|
||||
type LomsService interface {
|
||||
OrderCreate(ctx context.Context, orderReq *pb.OrderCreateRequest) (entity.ID, error)
|
||||
OrderInfo(ctx context.Context, orderID entity.ID) (*entity.Order, error)
|
||||
OrderPay(ctx context.Context, orderID entity.ID) error
|
||||
OrderCancel(ctx context.Context, orderID entity.ID) error
|
||||
StocksInfo(ctx context.Context, sku entity.Sku) (uint32, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
pb.UnimplementedLOMSServer
|
||||
|
||||
service LomsService
|
||||
}
|
||||
|
||||
func NewServer(lomsService LomsService) *Server {
|
||||
return &Server{
|
||||
service: lomsService,
|
||||
}
|
||||
}
|
||||
|
||||
func mapOrderStatus(pbStatus pb.OrderStatus) (string, error) {
|
||||
switch pbStatus {
|
||||
case pb.OrderStatus_ORDER_STATUS_AWAITING_PAYMENT:
|
||||
return "awaiting payment", nil
|
||||
case pb.OrderStatus_ORDER_STATUS_CANCELLED:
|
||||
return "cancelled", nil
|
||||
case pb.OrderStatus_ORDER_STATUS_FAILED:
|
||||
return "failed", nil
|
||||
case pb.OrderStatus_ORDER_STATUS_NEW:
|
||||
return "new", nil
|
||||
case pb.OrderStatus_ORDER_STATUS_PAYED:
|
||||
return "payed", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected OrderStatus: %v", pbStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) OrderCreate(ctx context.Context, req *pb.OrderCreateRequest) (*pb.OrderCreateResponse, error) {
|
||||
id, err := s.service.OrderCreate(ctx, req)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrInvalidInput):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, model.ErrNotEnoughStocks):
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
case err != nil:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &pb.OrderCreateResponse{OrderId: int64(id)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) OrderInfo(ctx context.Context, req *pb.OrderInfoRequest) (*pb.OrderInfoResponse, error) {
|
||||
ord, err := s.service.OrderInfo(ctx, entity.ID(req.OrderId))
|
||||
switch {
|
||||
case errors.Is(err, model.ErrInvalidInput):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, model.ErrOrderNotFound):
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
case err != nil:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
items := make([]*pb.OrderItem, len(ord.Items))
|
||||
for i, item := range ord.Items {
|
||||
items[i] = &pb.OrderItem{
|
||||
Sku: int64(item.ID),
|
||||
Count: item.Count,
|
||||
}
|
||||
}
|
||||
|
||||
orderStatus, err := mapOrderStatus(pb.OrderStatus(pb.OrderStatus_value[ord.Status]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &pb.OrderInfoResponse{
|
||||
Status: orderStatus,
|
||||
UserId: int64(ord.UserID),
|
||||
Items: items,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) OrderPay(ctx context.Context, req *pb.OrderPayRequest) (*emptypb.Empty, error) {
|
||||
err := s.service.OrderPay(ctx, entity.ID(req.OrderId))
|
||||
switch {
|
||||
case errors.Is(err, model.ErrInvalidInput):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, model.ErrOrderNotFound):
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
case errors.Is(err, model.ErrOrderInvalidStatus):
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
case err != nil:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) OrderCancel(ctx context.Context, req *pb.OrderCancelRequest) (*emptypb.Empty, error) {
|
||||
err := s.service.OrderCancel(ctx, entity.ID(req.OrderId))
|
||||
switch {
|
||||
case errors.Is(err, model.ErrInvalidInput):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, model.ErrOrderNotFound):
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
case errors.Is(err, model.ErrOrderInvalidStatus):
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
case err != nil:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) StocksInfo(ctx context.Context, req *pb.StocksInfoRequest) (*pb.StocksInfoResponse, error) {
|
||||
count, err := s.service.StocksInfo(ctx, entity.Sku(req.Sku))
|
||||
switch {
|
||||
case errors.Is(err, model.ErrInvalidInput):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, model.ErrOrderNotFound), errors.Is(err, model.ErrUnknownStock):
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
case err != nil:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
return &pb.StocksInfoResponse{Count: count}, nil
|
||||
}
|
||||
18
loms/internal/domain/entity/order.go
Normal file
18
loms/internal/domain/entity/order.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package entity
|
||||
|
||||
type (
|
||||
ID int64
|
||||
Sku int64
|
||||
)
|
||||
|
||||
type Order struct {
|
||||
OrderID ID
|
||||
Status string
|
||||
UserID ID
|
||||
Items []OrderItem
|
||||
}
|
||||
|
||||
type OrderItem struct {
|
||||
ID Sku
|
||||
Count uint32
|
||||
}
|
||||
6
loms/internal/domain/entity/stock.go
Normal file
6
loms/internal/domain/entity/stock.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package entity
|
||||
|
||||
type Stock struct {
|
||||
Item OrderItem
|
||||
Reserved uint32
|
||||
}
|
||||
13
loms/internal/domain/model/errors.go
Normal file
13
loms/internal/domain/model/errors.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
|
||||
ErrNotEnoughStocks = errors.New("not enough stocks")
|
||||
ErrUnknownStock = errors.New("unknown stock provided")
|
||||
|
||||
ErrOrderNotFound = errors.New("order not found")
|
||||
ErrOrderInvalidStatus = errors.New("invalid order status")
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"route256/loms/internal/domain/entity"
|
||||
"route256/loms/internal/domain/model"
|
||||
)
|
||||
|
||||
type storage = map[entity.ID]*entity.Order
|
||||
|
||||
type InMemoryRepository struct {
|
||||
storage storage
|
||||
mx sync.RWMutex
|
||||
idCounter entity.ID
|
||||
}
|
||||
|
||||
func NewInMemoryRepository(cap int) *InMemoryRepository {
|
||||
return &InMemoryRepository{
|
||||
storage: make(storage, cap),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) OrderCreate(_ context.Context, order *entity.Order) (entity.ID, error) {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
r.idCounter++
|
||||
|
||||
orderCopy := &entity.Order{
|
||||
OrderID: r.idCounter,
|
||||
Status: order.Status,
|
||||
UserID: order.UserID,
|
||||
Items: make([]entity.OrderItem, len(order.Items)),
|
||||
}
|
||||
|
||||
copy(orderCopy.Items, order.Items)
|
||||
|
||||
r.storage[orderCopy.OrderID] = orderCopy
|
||||
|
||||
return r.idCounter, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) OrderSetStatus(_ context.Context, orderID entity.ID, newStatus string) error {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
if _, ok := r.storage[orderID]; !ok {
|
||||
return model.ErrOrderNotFound
|
||||
}
|
||||
|
||||
r.storage[orderID].Status = newStatus
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) OrderGetByID(_ context.Context, orderID entity.ID) (*entity.Order, error) {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
order, ok := r.storage[orderID]
|
||||
if !ok {
|
||||
return nil, model.ErrOrderNotFound
|
||||
}
|
||||
|
||||
orderCopy := &entity.Order{
|
||||
OrderID: order.OrderID,
|
||||
Status: order.Status,
|
||||
UserID: order.UserID,
|
||||
Items: make([]entity.OrderItem, len(order.Items)),
|
||||
}
|
||||
|
||||
copy(orderCopy.Items, order.Items)
|
||||
|
||||
return orderCopy, nil
|
||||
}
|
||||
114
loms/internal/domain/repository/stocks/in_memory_repository.go
Normal file
114
loms/internal/domain/repository/stocks/in_memory_repository.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"route256/loms/internal/domain/entity"
|
||||
"route256/loms/internal/domain/model"
|
||||
)
|
||||
|
||||
//go:embed stock-data.json
|
||||
var stockData []byte
|
||||
|
||||
type storage = map[entity.Sku]*entity.Stock
|
||||
|
||||
type InMemoryRepository struct {
|
||||
storage storage
|
||||
mx sync.RWMutex
|
||||
}
|
||||
|
||||
func NewInMemoryRepository(cap int) (*InMemoryRepository, error) {
|
||||
var rows []struct {
|
||||
Sku entity.Sku `json:"sku"`
|
||||
TotalCount uint32 `json:"total_count"`
|
||||
Reserved uint32 `json:"reserved"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stockData, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repo := &InMemoryRepository{
|
||||
storage: make(storage, cap),
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
repo.storage[r.Sku] = &entity.Stock{
|
||||
Item: entity.OrderItem{
|
||||
ID: r.Sku,
|
||||
Count: r.TotalCount,
|
||||
},
|
||||
Reserved: r.Reserved,
|
||||
}
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) StockReserve(_ context.Context, stock *entity.Stock) error {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
if _, ok := r.storage[stock.Item.ID]; !ok {
|
||||
return model.ErrNotEnoughStocks
|
||||
}
|
||||
|
||||
if r.storage[stock.Item.ID].Item.Count < stock.Reserved {
|
||||
return model.ErrNotEnoughStocks
|
||||
}
|
||||
|
||||
r.storage[stock.Item.ID].Item.Count -= stock.Reserved
|
||||
r.storage[stock.Item.ID].Reserved += stock.Reserved
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) StockReserveRemove(_ context.Context, stock *entity.Stock) error {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
if _, ok := r.storage[stock.Item.ID]; !ok {
|
||||
return model.ErrUnknownStock
|
||||
}
|
||||
|
||||
if r.storage[stock.Item.ID].Reserved < stock.Reserved {
|
||||
return model.ErrNotEnoughStocks
|
||||
}
|
||||
|
||||
r.storage[stock.Item.ID].Reserved -= stock.Reserved
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) StockCancel(_ context.Context, stock *entity.Stock) error {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
if _, ok := r.storage[stock.Item.ID]; !ok {
|
||||
return model.ErrUnknownStock
|
||||
}
|
||||
|
||||
if r.storage[stock.Item.ID].Reserved < stock.Reserved {
|
||||
return model.ErrNotEnoughStocks
|
||||
}
|
||||
|
||||
r.storage[stock.Item.ID].Reserved -= stock.Reserved
|
||||
r.storage[stock.Item.ID].Item.Count += stock.Reserved
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryRepository) StockGetByID(_ context.Context, sku entity.Sku) (*entity.Stock, error) {
|
||||
r.mx.Lock()
|
||||
defer r.mx.Unlock()
|
||||
|
||||
stock, ok := r.storage[sku]
|
||||
if !ok {
|
||||
return nil, model.ErrUnknownStock
|
||||
}
|
||||
|
||||
return stock, nil
|
||||
}
|
||||
37
loms/internal/domain/repository/stocks/stock-data.json
Normal file
37
loms/internal/domain/repository/stocks/stock-data.json
Normal file
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"sku": 139275865,
|
||||
"total_count": 65534,
|
||||
"reserved": 0
|
||||
},
|
||||
{
|
||||
"sku": 2956315,
|
||||
"total_count": 100,
|
||||
"reserved": 30
|
||||
},
|
||||
{
|
||||
"sku": 1076963,
|
||||
"total_count": 100,
|
||||
"reserved": 35
|
||||
},
|
||||
{
|
||||
"sku": 135717466,
|
||||
"total_count": 100,
|
||||
"reserved": 20
|
||||
},
|
||||
{
|
||||
"sku": 135937324,
|
||||
"total_count": 100,
|
||||
"reserved": 30
|
||||
},
|
||||
{
|
||||
"sku": 1625903,
|
||||
"total_count": 10000,
|
||||
"reserved": 0
|
||||
},
|
||||
{
|
||||
"sku": 1148162,
|
||||
"total_count": 100,
|
||||
"reserved": 0
|
||||
}
|
||||
]
|
||||
1160
loms/internal/domain/service/mock/order_repository_mock.go
Normal file
1160
loms/internal/domain/service/mock/order_repository_mock.go
Normal file
File diff suppressed because it is too large
Load Diff
1483
loms/internal/domain/service/mock/stock_repository_mock.go
Normal file
1483
loms/internal/domain/service/mock/stock_repository_mock.go
Normal file
File diff suppressed because it is too large
Load Diff
186
loms/internal/domain/service/service.go
Normal file
186
loms/internal/domain/service/service.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"route256/loms/internal/domain/entity"
|
||||
"route256/loms/internal/domain/model"
|
||||
|
||||
pb "route256/pkg/api/loms/v1"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
//go:generate minimock -i OrderRepository -o ./mock -s _mock.go
|
||||
type OrderRepository interface {
|
||||
OrderCreate(ctx context.Context, order *entity.Order) (entity.ID, error)
|
||||
OrderSetStatus(ctx context.Context, orderID entity.ID, newStatus string) error
|
||||
OrderGetByID(ctx context.Context, orderID entity.ID) (*entity.Order, error)
|
||||
}
|
||||
|
||||
//go:generate minimock -i StockRepository -o ./mock -s _mock.go
|
||||
type StockRepository interface {
|
||||
StockReserve(ctx context.Context, stock *entity.Stock) error
|
||||
StockReserveRemove(ctx context.Context, stock *entity.Stock) error
|
||||
StockCancel(ctx context.Context, stock *entity.Stock) error
|
||||
StockGetByID(ctx context.Context, sku entity.Sku) (*entity.Stock, error)
|
||||
}
|
||||
|
||||
type LomsService struct {
|
||||
orders OrderRepository
|
||||
stocks StockRepository
|
||||
}
|
||||
|
||||
func NewLomsService(orderRepo OrderRepository, stockRepo StockRepository) *LomsService {
|
||||
return &LomsService{
|
||||
orders: orderRepo,
|
||||
stocks: stockRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LomsService) rollbackStocks(ctx context.Context, stocks []*entity.Stock) {
|
||||
for _, stock := range stocks {
|
||||
if err := s.stocks.StockCancel(ctx, stock); err != nil {
|
||||
log.Error().Err(err).Msg("failed to rollback stock")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LomsService) OrderCreate(ctx context.Context, orderReq *pb.OrderCreateRequest) (entity.ID, error) {
|
||||
if orderReq == nil || orderReq.UserId <= 0 || len(orderReq.Items) == 0 {
|
||||
return 0, model.ErrInvalidInput
|
||||
}
|
||||
|
||||
for _, item := range orderReq.Items {
|
||||
if item.Sku <= 0 || item.Count == 0 {
|
||||
return 0, model.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
|
||||
order := &entity.Order{
|
||||
OrderID: 0,
|
||||
Status: pb.OrderStatus_ORDER_STATUS_NEW.String(),
|
||||
UserID: entity.ID(orderReq.UserId),
|
||||
Items: make([]entity.OrderItem, len(orderReq.Items)),
|
||||
}
|
||||
|
||||
for i, item := range orderReq.Items {
|
||||
order.Items[i] = entity.OrderItem{
|
||||
ID: entity.Sku(item.Sku),
|
||||
Count: item.Count,
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortStableFunc(order.Items, func(a, b entity.OrderItem) int {
|
||||
return int(a.ID - b.ID)
|
||||
})
|
||||
|
||||
id, err := s.orders.OrderCreate(ctx, order)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("orders.OrderCreate: %w", err)
|
||||
}
|
||||
|
||||
order.OrderID = id
|
||||
|
||||
commitedStocks := make([]*entity.Stock, 0, len(order.Items))
|
||||
for _, item := range order.Items {
|
||||
stock := &entity.Stock{
|
||||
Item: item,
|
||||
Reserved: item.Count,
|
||||
}
|
||||
|
||||
if err := s.stocks.StockReserve(ctx, stock); err != nil {
|
||||
s.rollbackStocks(ctx, commitedStocks)
|
||||
|
||||
if statusErr := s.orders.OrderSetStatus(ctx, order.OrderID, pb.OrderStatus_ORDER_STATUS_FAILED.String()); statusErr != nil {
|
||||
log.Error().Err(statusErr).Msg("failed to update status on stock reserve fail")
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("stocks.StockReserve: %w", err)
|
||||
}
|
||||
|
||||
commitedStocks = append(commitedStocks, stock)
|
||||
}
|
||||
|
||||
if err := s.orders.OrderSetStatus(ctx, order.OrderID, pb.OrderStatus_ORDER_STATUS_AWAITING_PAYMENT.String()); err != nil {
|
||||
s.rollbackStocks(ctx, commitedStocks)
|
||||
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return order.OrderID, nil
|
||||
}
|
||||
|
||||
func (s *LomsService) OrderInfo(ctx context.Context, orderID entity.ID) (*entity.Order, error) {
|
||||
if orderID <= 0 {
|
||||
return nil, model.ErrInvalidInput
|
||||
}
|
||||
|
||||
return s.orders.OrderGetByID(ctx, orderID)
|
||||
}
|
||||
|
||||
func (s *LomsService) OrderPay(ctx context.Context, orderID entity.ID) error {
|
||||
order, err := s.OrderInfo(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch order.Status {
|
||||
case pb.OrderStatus_ORDER_STATUS_PAYED.String():
|
||||
return nil
|
||||
case pb.OrderStatus_ORDER_STATUS_AWAITING_PAYMENT.String():
|
||||
for _, item := range order.Items {
|
||||
if err := s.stocks.StockReserveRemove(ctx, &entity.Stock{
|
||||
Item: item,
|
||||
Reserved: item.Count,
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("failed to free stock reservation")
|
||||
}
|
||||
}
|
||||
|
||||
return s.orders.OrderSetStatus(ctx, orderID, pb.OrderStatus_ORDER_STATUS_PAYED.String())
|
||||
default:
|
||||
return model.ErrOrderInvalidStatus
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LomsService) OrderCancel(ctx context.Context, orderID entity.ID) error {
|
||||
order, err := s.OrderInfo(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch order.Status {
|
||||
case pb.OrderStatus_ORDER_STATUS_CANCELLED.String():
|
||||
return nil
|
||||
case pb.OrderStatus_ORDER_STATUS_FAILED.String(), pb.OrderStatus_ORDER_STATUS_PAYED.String():
|
||||
return model.ErrOrderInvalidStatus
|
||||
}
|
||||
|
||||
stocks := make([]*entity.Stock, len(order.Items))
|
||||
for i, item := range order.Items {
|
||||
stocks[i] = &entity.Stock{
|
||||
Item: item,
|
||||
Reserved: item.Count,
|
||||
}
|
||||
}
|
||||
|
||||
s.rollbackStocks(ctx, stocks)
|
||||
|
||||
return s.orders.OrderSetStatus(ctx, orderID, pb.OrderStatus_ORDER_STATUS_CANCELLED.String())
|
||||
}
|
||||
|
||||
func (s *LomsService) StocksInfo(ctx context.Context, sku entity.Sku) (uint32, error) {
|
||||
if sku <= 0 {
|
||||
return 0, model.ErrInvalidInput
|
||||
}
|
||||
|
||||
stock, err := s.stocks.StockGetByID(ctx, sku)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return stock.Item.Count, nil
|
||||
}
|
||||
448
loms/internal/domain/service/service_test.go
Normal file
448
loms/internal/domain/service/service_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gojuno/minimock/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"route256/loms/internal/domain/entity"
|
||||
"route256/loms/internal/domain/model"
|
||||
mock "route256/loms/internal/domain/service/mock"
|
||||
|
||||
pb "route256/pkg/api/loms/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
testUser = entity.ID(1337)
|
||||
testSku = entity.Sku(199)
|
||||
)
|
||||
|
||||
func TestLomsService_OrderCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
mc := minimock.NewController(t)
|
||||
|
||||
goodReq := &pb.OrderCreateRequest{
|
||||
UserId: int64(testUser),
|
||||
Items: []*pb.OrderItem{
|
||||
{
|
||||
Sku: int64(testSku),
|
||||
Count: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
badItemReq := &pb.OrderCreateRequest{
|
||||
UserId: int64(testUser),
|
||||
Items: []*pb.OrderItem{
|
||||
{
|
||||
Sku: 0,
|
||||
Count: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type fields struct {
|
||||
orders OrderRepository
|
||||
stocks StockRepository
|
||||
}
|
||||
type args struct {
|
||||
req *pb.OrderCreateRequest
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderCreateMock.Return(1, nil).
|
||||
OrderSetStatusMock.Return(nil),
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockReserveMock.Return(nil),
|
||||
},
|
||||
args: args{
|
||||
req: goodReq,
|
||||
},
|
||||
wantErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid input",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
req: &pb.OrderCreateRequest{
|
||||
UserId: 0,
|
||||
},
|
||||
},
|
||||
wantErr: func(t require.TestingT, err error, _ ...interface{}) {
|
||||
require.ErrorIs(t, err, model.ErrInvalidInput)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid input with bad items",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
req: badItemReq,
|
||||
},
|
||||
wantErr: func(t require.TestingT, err error, _ ...interface{}) {
|
||||
require.ErrorIs(t, err, model.ErrInvalidInput)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "order create error",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderCreateMock.Return(0, errors.New("order create error")),
|
||||
stocks: nil,
|
||||
},
|
||||
args: args{
|
||||
req: goodReq,
|
||||
},
|
||||
wantErr: require.Error,
|
||||
},
|
||||
{
|
||||
name: "stock reserve error",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderCreateMock.Return(1, nil).
|
||||
OrderSetStatusMock.Return(errors.New("status update error")),
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockReserveMock.Return(errors.New("reservation error")),
|
||||
},
|
||||
args: args{
|
||||
req: goodReq,
|
||||
},
|
||||
wantErr: require.Error,
|
||||
},
|
||||
{
|
||||
name: "final status update error",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderCreateMock.Return(1, nil).
|
||||
OrderSetStatusMock.Return(errors.New("unexpected error")),
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockReserveMock.Return(nil).
|
||||
StockCancelMock.Return(nil),
|
||||
},
|
||||
args: args{
|
||||
req: goodReq,
|
||||
},
|
||||
wantErr: require.Error,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewLomsService(tt.fields.orders, tt.fields.stocks)
|
||||
_, err := svc.OrderCreate(ctx, tt.args.req)
|
||||
tt.wantErr(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLomsService_OrderPay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
mc := minimock.NewController(t)
|
||||
|
||||
awaitingOrder := &entity.Order{
|
||||
OrderID: 1,
|
||||
Status: pb.OrderStatus_ORDER_STATUS_AWAITING_PAYMENT.String(),
|
||||
Items: []entity.OrderItem{{
|
||||
ID: testSku,
|
||||
Count: 2,
|
||||
}},
|
||||
}
|
||||
|
||||
payedOrder := &entity.Order{OrderID: 2, Status: pb.OrderStatus_ORDER_STATUS_PAYED.String()}
|
||||
badStatusOrder := &entity.Order{OrderID: 3, Status: pb.OrderStatus_ORDER_STATUS_FAILED.String()}
|
||||
|
||||
type fields struct {
|
||||
orders OrderRepository
|
||||
stocks StockRepository
|
||||
}
|
||||
type args struct {
|
||||
id entity.ID
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderGetByIDMock.Return(awaitingOrder, nil).
|
||||
OrderSetStatusMock.Return(nil),
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockReserveRemoveMock.Return(nil),
|
||||
},
|
||||
args: args{
|
||||
id: awaitingOrder.OrderID,
|
||||
},
|
||||
wantErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid input",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc),
|
||||
stocks: mock.NewStockRepositoryMock(mc),
|
||||
},
|
||||
args: args{
|
||||
id: 0,
|
||||
},
|
||||
wantErr: func(t require.TestingT, err error, _ ...interface{}) {
|
||||
require.ErrorIs(t, err, model.ErrInvalidInput)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "already payed",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderGetByIDMock.Return(payedOrder, nil),
|
||||
},
|
||||
args: args{
|
||||
id: payedOrder.OrderID,
|
||||
},
|
||||
wantErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderGetByIDMock.Return(badStatusOrder, nil),
|
||||
},
|
||||
args: args{
|
||||
id: badStatusOrder.OrderID,
|
||||
},
|
||||
wantErr: func(t require.TestingT, err error, _ ...interface{}) {
|
||||
require.ErrorIs(t, err, model.ErrOrderInvalidStatus)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unexpected logged error on updating stocks reserves",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderGetByIDMock.Return(awaitingOrder, nil).
|
||||
OrderSetStatusMock.Return(nil),
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockReserveRemoveMock.Return(errors.New("unexpected error")),
|
||||
},
|
||||
args: args{
|
||||
id: badStatusOrder.OrderID,
|
||||
},
|
||||
wantErr: require.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewLomsService(tt.fields.orders, tt.fields.stocks)
|
||||
err := svc.OrderPay(ctx, tt.args.id)
|
||||
tt.wantErr(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLomsService_OrderInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mc := minimock.NewController(t)
|
||||
svc := NewLomsService(
|
||||
mock.NewOrderRepositoryMock(mc),
|
||||
mock.NewStockRepositoryMock(mc),
|
||||
)
|
||||
|
||||
err := svc.OrderPay(context.Background(), 0)
|
||||
require.ErrorIs(t, err, model.ErrInvalidInput)
|
||||
}
|
||||
|
||||
func TestLomsService_OrderCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
mc := minimock.NewController(t)
|
||||
|
||||
awaiting := &entity.Order{
|
||||
OrderID: 1,
|
||||
Status: pb.OrderStatus_ORDER_STATUS_AWAITING_PAYMENT.String(),
|
||||
Items: []entity.OrderItem{{
|
||||
ID: testSku, Count: 1,
|
||||
}},
|
||||
}
|
||||
cancelled := &entity.Order{
|
||||
OrderID: 2,
|
||||
Status: pb.OrderStatus_ORDER_STATUS_CANCELLED.String(),
|
||||
}
|
||||
payed := &entity.Order{
|
||||
OrderID: 3,
|
||||
Status: pb.OrderStatus_ORDER_STATUS_PAYED.String(),
|
||||
}
|
||||
|
||||
type fields struct {
|
||||
orders OrderRepository
|
||||
stocks StockRepository
|
||||
}
|
||||
type args struct {
|
||||
id entity.ID
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderGetByIDMock.Return(awaiting, nil).
|
||||
OrderSetStatusMock.Return(nil),
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockCancelMock.Return(nil),
|
||||
},
|
||||
args: args{
|
||||
id: awaiting.OrderID,
|
||||
},
|
||||
wantErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid input",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc),
|
||||
stocks: mock.NewStockRepositoryMock(mc),
|
||||
},
|
||||
args: args{
|
||||
id: 0,
|
||||
},
|
||||
wantErr: func(t require.TestingT, err error, _ ...interface{}) {
|
||||
require.ErrorIs(t, err, model.ErrInvalidInput)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "already cancelled",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderGetByIDMock.Return(cancelled, nil),
|
||||
},
|
||||
args: args{
|
||||
id: cancelled.OrderID,
|
||||
},
|
||||
wantErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
fields: fields{
|
||||
orders: mock.NewOrderRepositoryMock(mc).
|
||||
OrderGetByIDMock.Return(payed, nil),
|
||||
},
|
||||
args: args{
|
||||
id: payed.OrderID,
|
||||
},
|
||||
wantErr: func(t require.TestingT, err error, _ ...interface{}) {
|
||||
require.ErrorIs(t, err, model.ErrOrderInvalidStatus)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewLomsService(tt.fields.orders, tt.fields.stocks)
|
||||
err := svc.OrderCancel(ctx, tt.args.id)
|
||||
tt.wantErr(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLomsService_StocksInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
mc := minimock.NewController(t)
|
||||
|
||||
type fields struct{ stocks StockRepository }
|
||||
type args struct{ sku entity.Sku }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want uint32
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockGetByIDMock.Return(&entity.Stock{
|
||||
Item: entity.OrderItem{
|
||||
Count: 7,
|
||||
},
|
||||
}, nil),
|
||||
},
|
||||
args: args{
|
||||
sku: testSku,
|
||||
},
|
||||
want: 7,
|
||||
wantErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid sku",
|
||||
fields: fields{},
|
||||
args: args{
|
||||
sku: 0,
|
||||
},
|
||||
wantErr: func(t require.TestingT, err error, _ ...interface{}) {
|
||||
require.ErrorIs(t, err, model.ErrInvalidInput)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get by id error",
|
||||
fields: fields{
|
||||
stocks: mock.NewStockRepositoryMock(mc).
|
||||
StockGetByIDMock.Return(nil, errors.New("unexpected error")),
|
||||
},
|
||||
args: args{
|
||||
sku: testSku,
|
||||
},
|
||||
wantErr: require.Error,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := NewLomsService(nil, tt.fields.stocks)
|
||||
got, err := svc.StocksInfo(ctx, tt.args.sku)
|
||||
tt.wantErr(t, err)
|
||||
if err == nil {
|
||||
assert.Equal(t, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
74
loms/internal/infra/config/config.go
Normal file
74
loms/internal/infra/config/config.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Service struct {
|
||||
Host string `yaml:"host"`
|
||||
GRPCPort string `yaml:"grpc_port"`
|
||||
HTTPPort string `yaml:"http_port"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
} `yaml:"service"`
|
||||
|
||||
Jaeger struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
} `yaml:"jaeger"`
|
||||
|
||||
DatabaseMaster struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"db_name"`
|
||||
} `yaml:"db_master"`
|
||||
|
||||
DatabaseReplica struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"db_name"`
|
||||
} `yaml:"db_replica"`
|
||||
|
||||
Kafka struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
OrderTopic string `yaml:"order_topic"`
|
||||
Brokers string `yaml:"brokers"`
|
||||
} `yaml:"kafka"`
|
||||
}
|
||||
|
||||
func LoadConfig(filename string) (*Config, error) {
|
||||
workDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgRoot := filepath.Join(workDir, "configs")
|
||||
absCfgRoot, _ := filepath.Abs(cfgRoot)
|
||||
|
||||
filePath := filepath.Join(absCfgRoot, filepath.Clean(filename))
|
||||
if !strings.HasPrefix(filePath, absCfgRoot) {
|
||||
return nil, fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
config := &Config{}
|
||||
if err := yaml.NewDecoder(f).Decode(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
24
loms/internal/infra/grpc/middleware/logging.go
Normal file
24
loms/internal/infra/grpc/middleware/logging.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package mw
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func Logging(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
|
||||
raw, _ := protojson.Marshal((req).(proto.Message))
|
||||
log.Debug().Msgf("request: method: %v, req: %s", info.FullMethod, string(raw))
|
||||
|
||||
if resp, err = handler(ctx, req); err != nil {
|
||||
log.Debug().Msgf("response: method: %v, err: %s", info.FullMethod, err.Error())
|
||||
return
|
||||
}
|
||||
rawResp, _ := protojson.Marshal((resp).(proto.Message))
|
||||
log.Debug().Msgf("response: method: %v, resp: %s", info.FullMethod, string(rawResp))
|
||||
|
||||
return
|
||||
}
|
||||
18
loms/internal/infra/grpc/middleware/validate.go
Normal file
18
loms/internal/infra/grpc/middleware/validate.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package mw
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func Validate(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
if v, ok := req.(interface{ Validate() error }); ok {
|
||||
if err := v.Validate(); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
}
|
||||
return handler(ctx, req)
|
||||
}
|
||||
130
loms/tests/integration/loms_integration_test.go
Normal file
130
loms/tests/integration/loms_integration_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ozontech/allure-go/pkg/framework/provider"
|
||||
"github.com/ozontech/allure-go/pkg/framework/suite"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"route256/loms/internal/app/server"
|
||||
"route256/loms/internal/domain/entity"
|
||||
ordersRepository "route256/loms/internal/domain/repository/orders"
|
||||
stocksRepository "route256/loms/internal/domain/repository/stocks"
|
||||
lomsService "route256/loms/internal/domain/service"
|
||||
|
||||
pb "route256/pkg/api/loms/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// "total_count": 100,
|
||||
// "reserved": 35
|
||||
testSKU = entity.Sku(1076963)
|
||||
|
||||
testUID = entity.ID(1337)
|
||||
testCount = uint32(2)
|
||||
)
|
||||
|
||||
type LomsIntegrationSuite struct {
|
||||
suite.Suite
|
||||
|
||||
grpcSrv *grpc.Server
|
||||
grpcConn *grpc.ClientConn
|
||||
lomsClient pb.LOMSClient
|
||||
}
|
||||
|
||||
func TestLomsIntegrationSuite(t *testing.T) {
|
||||
suite.RunSuite(t, new(LomsIntegrationSuite))
|
||||
}
|
||||
|
||||
func (s *LomsIntegrationSuite) BeforeAll(t provider.T) {
|
||||
t.WithNewStep("init cart-service", func(sCtx provider.StepCtx) {
|
||||
orderRepo := ordersRepository.NewInMemoryRepository(100)
|
||||
stockRepo, err := stocksRepository.NewInMemoryRepository(100)
|
||||
sCtx.Require().NoError(err)
|
||||
|
||||
svc := lomsService.NewLomsService(orderRepo, stockRepo)
|
||||
lomsServer := server.NewServer(svc)
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
sCtx.Require().NoError(err)
|
||||
|
||||
s.grpcSrv = grpc.NewServer()
|
||||
pb.RegisterLOMSServer(s.grpcSrv, lomsServer)
|
||||
|
||||
go func() { _ = s.grpcSrv.Serve(lis) }()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
|
||||
sCtx.Require().NoError(err)
|
||||
|
||||
s.grpcConn = conn
|
||||
s.lomsClient = pb.NewLOMSClient(conn)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LomsIntegrationSuite) AfterAll(t provider.T) {
|
||||
s.grpcSrv.Stop()
|
||||
_ = s.grpcConn.Close()
|
||||
}
|
||||
|
||||
func (s *LomsIntegrationSuite) TestOrderProcessPositive(t provider.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var orderID int64
|
||||
|
||||
t.WithNewStep("create order", func(sCtx provider.StepCtx) {
|
||||
req := &pb.OrderCreateRequest{
|
||||
UserId: int64(testUID),
|
||||
Items: []*pb.OrderItem{{
|
||||
Sku: int64(testSKU),
|
||||
Count: testCount,
|
||||
}},
|
||||
}
|
||||
|
||||
resp, err := s.lomsClient.OrderCreate(ctx, req)
|
||||
sCtx.Require().NoError(err)
|
||||
sCtx.Require().Greater(resp.OrderId, int64(0))
|
||||
orderID = resp.OrderId
|
||||
})
|
||||
|
||||
t.WithNewStep("verify order info (NEW)", func(sCtx provider.StepCtx) {
|
||||
resp, err := s.lomsClient.OrderInfo(ctx, &pb.OrderInfoRequest{OrderId: orderID})
|
||||
sCtx.Require().NoError(err)
|
||||
|
||||
sCtx.Require().Equal("awaiting payment", resp.Status)
|
||||
sCtx.Require().Equal(int64(testUID), resp.UserId)
|
||||
sCtx.Require().Len(resp.Items, 1)
|
||||
sCtx.Require().Equal(int64(testSKU), resp.Items[0].Sku)
|
||||
sCtx.Require().Equal(testCount, resp.Items[0].Count)
|
||||
})
|
||||
|
||||
t.WithNewStep("pay order", func(sCtx provider.StepCtx) {
|
||||
_, err := s.lomsClient.OrderPay(ctx, &pb.OrderPayRequest{OrderId: orderID})
|
||||
sCtx.Require().NoError(err)
|
||||
})
|
||||
|
||||
t.WithNewStep("verify order info (PAYED)", func(sCtx provider.StepCtx) {
|
||||
resp, err := s.lomsClient.OrderInfo(ctx, &pb.OrderInfoRequest{OrderId: orderID})
|
||||
sCtx.Require().NoError(err)
|
||||
sCtx.Require().Equal("payed", resp.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LomsIntegrationSuite) TestStocksInfoPositive(t provider.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.WithNewStep("call StocksInfo", func(sCtx provider.StepCtx) {
|
||||
resp, err := s.lomsClient.StocksInfo(ctx, &pb.StocksInfoRequest{Sku: int64(testSKU)})
|
||||
sCtx.Require().NoError(err)
|
||||
sCtx.Require().Greater(resp.Count, uint32(0))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user