[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

View File

@@ -0,0 +1,211 @@
package service
import (
"context"
"fmt"
"slices"
"sync"
"github.com/rs/zerolog/log"
"route256/cart/internal/domain/entity"
"route256/cart/internal/domain/model"
)
//go:generate minimock -i Repository -o ./mock -s _mock.go
type Repository interface {
AddItem(ctx context.Context, userID entity.UID, item *model.Item) error
GetItemsByUserID(ctx context.Context, userID entity.UID) (entity.Cart, error)
DeleteItem(ctx context.Context, userID entity.UID, sku entity.Sku) error
DeleteItemsByUserID(ctx context.Context, userID entity.UID) error
}
type ProductService interface {
GetProductBySku(ctx context.Context, sku entity.Sku) (*model.Product, error)
}
type LomsService interface {
OrderCreate(ctx context.Context, cart *model.Cart) (int64, error)
StocksInfo(ctx context.Context, sku entity.Sku) (uint32, error)
}
type CartService struct {
repository Repository
productService ProductService
lomsService LomsService
}
func NewCartService(repository Repository, productService ProductService, lomsService LomsService) *CartService {
return &CartService{
repository: repository,
productService: productService,
lomsService: lomsService,
}
}
func (s *CartService) AddItem(ctx context.Context, userID entity.UID, item *model.Item) error {
if err := item.Validate(); err != nil {
return fmt.Errorf("invalid requested values: %w", err)
}
if userID <= 0 {
return fmt.Errorf("invalid userID")
}
_, err := s.productService.GetProductBySku(ctx, item.Product.Sku)
if err != nil {
return fmt.Errorf("productService.GetProductBySku: %w", err)
}
count, err := s.lomsService.StocksInfo(ctx, item.Product.Sku)
if err != nil {
return fmt.Errorf("lomsService.StocksInfo: %w", err)
}
if count < item.Count {
return model.ErrNotEnoughStocks
}
if err := s.repository.AddItem(ctx, userID, item); err != nil {
return fmt.Errorf("repository.AddItemToCart: %w", err)
}
return nil
}
// GetUserCart gets all user cart's item ids, gets the item description from the product-service
// and return a list of the collected items.
// In case of failed request to product-service, return nothing and error.
//
// TODO: add worker group, BUT it's OK for now,
// assuming user does not have hundreds of different items in his cart.
func (s *CartService) GetItemsByUserID(ctx context.Context, userID entity.UID) (*model.Cart, error) {
if userID <= 0 {
return nil, fmt.Errorf("userID invalid")
}
cart, err := s.repository.GetItemsByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("repository.AddItemToCart: %w", err)
}
if len(cart.Items) == 0 {
return nil, model.ErrCartNotFound
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
resultCart := &model.Cart{
UserID: userID,
Items: make([]*model.Item, len(cart.Items)),
TotalPrice: 0,
}
errCh := make(chan error, 1)
var (
wg sync.WaitGroup
sumMutex sync.Mutex
)
for idx, sku := range cart.Items {
wg.Add(1)
go func(sku entity.Sku, count uint32, idx int) {
defer wg.Done()
product, err := s.productService.GetProductBySku(ctx, sku)
if err != nil {
select {
case errCh <- fmt.Errorf("productService.GetProductBySku: %w", err):
case <-ctx.Done():
}
log.Error().Err(err).Msg("productService.GetProductBySku")
return
}
resultCart.Items[idx] = &model.Item{
Product: product,
Count: count,
}
sumMutex.Lock()
resultCart.TotalPrice += uint32(product.Price) * count
sumMutex.Unlock()
}(sku, cart.ItemCount[sku], idx)
}
doneCh := make(chan struct{})
go func() {
wg.Wait()
close(doneCh)
}()
select {
case err := <-errCh:
cancel()
return nil, err
case <-doneCh:
slices.SortStableFunc(resultCart.Items, func(a, b *model.Item) int {
return int(a.Product.Sku - b.Product.Sku)
})
return resultCart, nil
}
}
func (s *CartService) DeleteItem(ctx context.Context, userID entity.UID, sku entity.Sku) error {
if userID <= 0 {
return fmt.Errorf("userID invalid")
}
if sku <= 0 {
return fmt.Errorf("sku invalid")
}
if err := s.repository.DeleteItem(ctx, userID, sku); err != nil {
return fmt.Errorf("repository.DeleteItemFromUserCart: %w", err)
}
return nil
}
func (s *CartService) DeleteItemsByUserID(ctx context.Context, userID entity.UID) error {
if userID <= 0 {
return fmt.Errorf("userID invalid")
}
if err := s.repository.DeleteItemsByUserID(ctx, userID); err != nil {
return fmt.Errorf("repository.DeleteUserCart: %w", err)
}
return nil
}
func (s *CartService) CheckoutUserCart(ctx context.Context, userID entity.UID) (int64, error) {
if userID <= 0 {
return 0, fmt.Errorf("userID invalid")
}
cart, err := s.GetItemsByUserID(ctx, entity.UID(userID))
if err != nil {
return 0, err
}
orderID, err := s.lomsService.OrderCreate(ctx, cart)
if err != nil {
return 0, fmt.Errorf("lomsService.OrderCreate: %w", err)
}
if err := s.DeleteItemsByUserID(ctx, userID); err != nil {
return 0, err
}
return orderID, nil
}

View File

@@ -0,0 +1,758 @@
package service
import (
"context"
"errors"
"testing"
"github.com/gojuno/minimock/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"route256/cart/internal/domain/entity"
"route256/cart/internal/domain/model"
"route256/cart/internal/domain/service/mock"
)
const (
validPrice = 123
validName = "some product name"
)
type productServiceFake struct{}
func (f *productServiceFake) GetProductBySku(_ context.Context, sku entity.Sku) (*model.Product, error) {
if sku%2 == 0 {
return nil, errors.New("empty shelf")
}
return &model.Product{
Name: validName,
Price: validPrice,
Sku: sku,
}, nil
}
type lomsServiceFake struct{}
func (f *lomsServiceFake) StocksInfo(_ context.Context, sku entity.Sku) (uint32, error) {
if sku == 1111 {
return 0, errors.New("stock error")
}
return 100, nil
}
func (f *lomsServiceFake) OrderCreate(_ context.Context, cart *model.Cart) (int64, error) {
if cart.UserID == 1111 {
return 0, errors.New("order create error")
}
return 1234, nil
}
func TestCartService_AddItem(t *testing.T) {
t.Parallel()
ctx := context.Background()
mc := minimock.NewController(t)
testSKU := entity.Sku(199)
testItem := model.Item{
Product: &model.Product{
Name: validName,
Price: 123,
Sku: testSKU,
},
Count: 1,
}
testSKULomsFailing := entity.Sku(1111)
testItemLomsFailing := model.Item{
Product: &model.Product{
Name: validName,
Price: validPrice,
Sku: testSKULomsFailing,
},
Count: 1,
}
type fields struct {
repository Repository
productService ProductService
lomsService LomsService
}
type args struct {
ctx context.Context
item *model.Item
userID entity.UID
}
tests := []struct {
name string
fields fields
args args
wantErr require.ErrorAssertionFunc
}{
{
name: "success",
fields: fields{
repository: mock.NewRepositoryMock(mc).
AddItemMock.
Expect(ctx, 1337, &testItem).
Return(nil),
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{
ctx: ctx,
item: &testItem,
userID: 1337,
},
wantErr: require.NoError,
},
{
name: "invalid item",
fields: fields{
repository: nil,
productService: nil,
},
args: args{
ctx: ctx,
item: &model.Item{
Product: &model.Product{
Name: "name",
Price: 123,
Sku: 0,
},
Count: 0,
},
userID: 0,
},
wantErr: require.Error,
},
{
name: "invalid user id",
fields: fields{
repository: nil,
productService: nil,
},
args: args{
ctx: ctx,
item: &testItem,
userID: 0,
},
wantErr: require.Error,
},
{
name: "product service error",
fields: fields{
repository: nil,
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{
ctx: ctx,
item: &model.Item{
Product: &model.Product{
Name: "",
Price: 0,
Sku: 4,
},
Count: 1,
},
userID: 1337,
},
wantErr: require.Error,
},
{
name: "repository error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
AddItemMock.
Expect(ctx, 1337, &testItem).
Return(assert.AnError),
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{
ctx: ctx,
item: &testItem,
userID: 1337,
},
wantErr: require.Error,
},
{
name: "stocks acquiring error",
fields: fields{
repository: nil,
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{
ctx: ctx,
item: &testItemLomsFailing,
userID: 1337,
},
wantErr: require.Error,
},
{
name: "not enough stocks",
fields: fields{
repository: nil,
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{
ctx: ctx,
item: &model.Item{
Product: &model.Product{
Name: validName,
Price: validPrice,
Sku: testSKU,
},
Count: 10000,
},
userID: 1337,
},
wantErr: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := &CartService{
repository: tt.fields.repository,
productService: tt.fields.productService,
lomsService: tt.fields.lomsService,
}
err := s.AddItem(tt.args.ctx, tt.args.userID, tt.args.item)
tt.wantErr(t, err, "check add review error")
})
}
}
func TestCartService_GetItemsByUserID(t *testing.T) {
t.Parallel()
ctx := context.Background()
mc := minimock.NewController(t)
testUID := entity.UID(1337)
testSKU := entity.Sku(199)
testModelCart := model.Cart{
UserID: testUID,
Items: []*model.Item{
{
Product: &model.Product{
Name: validName,
Price: validPrice,
Sku: testSKU,
},
Count: 1,
},
},
TotalPrice: validPrice,
}
testEntityCart := entity.Cart{
UserID: testUID,
Items: []entity.Sku{testSKU},
ItemCount: map[entity.Sku]uint32{testSKU: 1},
}
type fields struct {
repository Repository
productService ProductService
}
type args struct {
ctx context.Context
userID entity.UID
}
tests := []struct {
name string
fields fields
args args
want *model.Cart
wantErr require.ErrorAssertionFunc
}{
{
name: "success 1 item",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, testUID).
Return(testEntityCart, nil),
productService: &productServiceFake{},
},
args: args{
ctx: ctx,
userID: testUID,
},
want: &testModelCart,
wantErr: require.NoError,
},
{
name: "success 2 items",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, testUID).
Return(entity.Cart{
UserID: testUID,
Items: []entity.Sku{testSKU},
ItemCount: map[entity.Sku]uint32{testSKU: 2},
}, nil),
productService: &productServiceFake{},
},
args: args{
ctx: ctx,
userID: testUID,
},
want: &model.Cart{
UserID: testUID,
Items: []*model.Item{
{
Product: &model.Product{
Name: validName,
Price: validPrice,
Sku: testSKU,
},
Count: 2,
},
},
TotalPrice: validPrice * 2,
},
wantErr: require.NoError,
},
{
name: "success 2 different items",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, testUID).
Return(entity.Cart{
UserID: testUID,
Items: []entity.Sku{testSKU, 1},
ItemCount: map[entity.Sku]uint32{testSKU: 1, 1: 1},
}, nil),
productService: &productServiceFake{},
},
args: args{
ctx: ctx,
userID: testUID,
},
want: &model.Cart{
UserID: testUID,
Items: []*model.Item{
{
Product: &model.Product{
Name: validName,
Price: validPrice,
Sku: 1,
},
Count: 1,
},
{
Product: &model.Product{
Name: validName,
Price: validPrice,
Sku: testSKU,
},
Count: 1,
},
},
TotalPrice: validPrice * 2,
},
wantErr: require.NoError,
},
{
name: "invalid user id",
fields: fields{
repository: nil,
productService: nil,
},
args: args{
ctx: ctx,
userID: 0,
},
want: nil,
wantErr: require.Error,
},
{
name: "repository error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, testUID).
Return(entity.Cart{}, assert.AnError),
productService: nil,
},
args: args{
ctx: ctx,
userID: testUID,
},
want: nil,
wantErr: require.Error,
},
{
name: "empty cart",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, testUID).
Return(entity.Cart{}, nil),
productService: nil,
},
args: args{
ctx: ctx,
userID: testUID,
},
want: nil,
wantErr: require.Error,
},
{
name: "product service error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, testUID).
Return(entity.Cart{
UserID: testUID,
Items: []entity.Sku{2},
ItemCount: map[entity.Sku]uint32{2: 1},
}, nil),
productService: &productServiceFake{},
},
args: args{
ctx: ctx,
userID: testUID,
},
want: nil,
wantErr: require.Error,
},
{
name: "product service error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, testUID).
Return(entity.Cart{
UserID: testUID,
Items: []entity.Sku{2},
ItemCount: map[entity.Sku]uint32{2: 1},
}, nil),
productService: &productServiceFake{},
},
args: args{
ctx: ctx,
userID: testUID,
},
want: nil,
wantErr: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := &CartService{
repository: tt.fields.repository,
productService: tt.fields.productService,
}
got, err := s.GetItemsByUserID(tt.args.ctx, tt.args.userID)
tt.wantErr(t, err, "check add review error")
assert.True(t, assert.EqualExportedValues(t, tt.want, got), "got unexpected cart")
})
}
}
func TestCartService_DeleteItem(t *testing.T) {
t.Parallel()
ctx := context.Background()
mc := minimock.NewController(t)
testSKU := entity.Sku(199)
testUID := entity.UID(1337)
type fields struct {
repository Repository
}
type args struct {
ctx context.Context
userID entity.UID
sku entity.Sku
}
tests := []struct {
name string
fields fields
args args
wantErr require.ErrorAssertionFunc
}{
{
name: "success",
fields: fields{
repository: mock.NewRepositoryMock(mc).
DeleteItemMock.
Expect(ctx, testUID, testSKU).
Return(nil),
},
args: args{
ctx: ctx,
userID: testUID,
sku: testSKU,
},
wantErr: require.NoError,
},
{
name: "invalid user id",
fields: fields{
repository: nil,
},
args: args{
ctx: ctx,
userID: 0,
sku: testSKU,
},
wantErr: require.Error,
},
{
name: "invalid sku",
fields: fields{
repository: nil,
},
args: args{
ctx: ctx,
userID: testUID,
sku: 0,
},
wantErr: require.Error,
},
{
name: "repository error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
DeleteItemMock.
Expect(ctx, testUID, testSKU).
Return(assert.AnError),
},
args: args{
ctx: ctx,
sku: testSKU,
userID: testUID,
},
wantErr: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := &CartService{
repository: tt.fields.repository,
}
err := s.DeleteItem(tt.args.ctx, tt.args.userID, tt.args.sku)
tt.wantErr(t, err, "check add review error")
})
}
}
func TestCartService_DeleteItemsByUserID(t *testing.T) {
t.Parallel()
ctx := context.Background()
mc := minimock.NewController(t)
testUID := entity.UID(1337)
type fields struct {
repository Repository
}
type args struct {
ctx context.Context
userID entity.UID
}
tests := []struct {
name string
fields fields
args args
wantErr require.ErrorAssertionFunc
}{
{
name: "success",
fields: fields{
repository: mock.NewRepositoryMock(mc).
DeleteItemsByUserIDMock.
Expect(ctx, testUID).
Return(nil),
},
args: args{
ctx: ctx,
userID: testUID,
},
wantErr: require.NoError,
},
{
name: "invalid user id",
fields: fields{
repository: nil,
},
args: args{
ctx: ctx,
userID: 0,
},
wantErr: require.Error,
},
{
name: "repository error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
DeleteItemsByUserIDMock.
Expect(ctx, testUID).
Return(assert.AnError),
},
args: args{
ctx: ctx,
userID: testUID,
},
wantErr: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := &CartService{
repository: tt.fields.repository,
}
err := s.DeleteItemsByUserID(tt.args.ctx, tt.args.userID)
tt.wantErr(t, err, "check add review error")
})
}
}
func TestCartService_CheckoutUserCart(t *testing.T) {
t.Parallel()
ctx := context.Background()
mc := minimock.NewController(t)
testSKU := entity.Sku(199)
testCart := entity.Cart{
Items: []entity.Sku{testSKU},
ItemCount: map[entity.Sku]uint32{testSKU: 1},
}
type fields struct {
repository Repository
productService ProductService
lomsService LomsService
}
type args struct {
ctx context.Context
userID entity.UID
}
tests := []struct {
name string
fields fields
args args
wantOrderID int64
wantErr require.ErrorAssertionFunc
}{
{
name: "success",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, 1337).
Return(testCart, nil).
DeleteItemsByUserIDMock.
Expect(ctx, 1337).
Return(nil),
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{ctx: ctx, userID: 1337},
wantOrderID: 1234,
wantErr: require.NoError,
},
{
name: "invalid user id",
fields: fields{},
args: args{ctx: ctx, userID: 0},
wantErr: require.Error,
},
{
name: "get cart error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, 1337).
Return(entity.Cart{}, assert.AnError),
},
args: args{ctx: ctx, userID: 1337},
wantErr: require.Error,
},
{
name: "order create error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, 1111).
Return(testCart, nil),
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{ctx: ctx, userID: 1111},
wantErr: require.Error,
},
{
name: "delete order error",
fields: fields{
repository: mock.NewRepositoryMock(mc).
GetItemsByUserIDMock.
Expect(ctx, 1337).
Return(testCart, nil).
DeleteItemsByUserIDMock.
Expect(ctx, 1337).
Return(assert.AnError),
productService: &productServiceFake{},
lomsService: &lomsServiceFake{},
},
args: args{ctx: ctx, userID: 1337},
wantErr: require.Error,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
svc := &CartService{
repository: tt.fields.repository,
productService: tt.fields.productService,
lomsService: tt.fields.lomsService,
}
orderID, err := svc.CheckoutUserCart(tt.args.ctx, tt.args.userID)
tt.wantErr(t, err)
if err == nil {
require.Equal(t, tt.wantOrderID, orderID)
}
})
}
}