[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

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