[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

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