[hw-3] loms service

This commit is contained in:
Никита Шубин
2025-06-20 10:11:59 +00:00
parent c8e056bc99
commit b88dfe6db5
73 changed files with 8837 additions and 52 deletions

119
loms/internal/app/app.go Normal file
View 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()
}

View 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
}

View 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
}

View File

@@ -0,0 +1,6 @@
package entity
type Stock struct {
Item OrderItem
Reserved uint32
}

View 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")
)

View File

@@ -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
}

View 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
}

View 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
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}

View 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)
}
})
}
}

View 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
}

View 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
}

View 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)
}