feat: product item
This commit is contained in:
parent
6f27ff3bbc
commit
2e2bdecc48
|
@ -7,24 +7,26 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProductItems struct {
|
type ProductItems struct {
|
||||||
ID primitive.ObjectID `bson:"_id,omitempty"` // 專案 ID
|
ID primitive.ObjectID `bson:"_id,omitempty"` // 專案 ID
|
||||||
ProductID string `bson:"product_id"` // 對應的專案 ID
|
ReferenceID string `bson:"reference_id"` // 對應的專案 ID
|
||||||
Name string `bson:"name" json:"name"` // 名稱
|
Name string `bson:"name"` // 名稱
|
||||||
Cover string `bson:"cover"` // 封面
|
Description string `bson:"description"` // 描述
|
||||||
Description string `bson:"description"` // 描述
|
ShortDescription string `bson:"short_description"` // 封面簡短描述
|
||||||
ShortDescription string `bson:"short_description"` // 封面簡短描述
|
IsUnLimit bool `bson:"is_un_limit"` // 是否沒有數量上限
|
||||||
Price decimal.Decimal `bson:"price"` // 價格
|
IsFree bool `bson:"is_free"` // 是否為免費品項(贈品) -> 開啟就是自訂金額
|
||||||
SKU string `bson:"sku"` // 型號:對應顯示 Item 的 FK
|
Stock uint64 `bson:"stock"` // 庫存總數
|
||||||
TimeSeries product.TimeSeries `bson:"time_series"` // 時段種類
|
Price decimal.Decimal `bson:"price"` // 價格
|
||||||
Media []Media `bson:"medias,omitempty"` // 專案動態內容(圖片或者影片)
|
SKU string `bson:"sku"` // 型號:對應顯示 Item 的 FK
|
||||||
AverageRating float64 `bson:"average_rating"` // 綜合評價(如:4.5 顆星)
|
TimeSeries product.TimeSeries `bson:"time_series"` // 時段種類
|
||||||
AverageRatingUpdateTime int64 `bson:"average_rating_time"` // 更新評價的時間
|
Media []Media `bson:"media,omitempty"` // 專案動態內容(圖片或者影片)
|
||||||
Orders uint64 `bson:"total_orders"` // 總接單數
|
Status product.ItemStatus `bson:"status"` // 商品狀態
|
||||||
OrdersUpdateTime int64 `bson:"total_orders_update_time"` // 更新總接單數的時間
|
Freight []CustomFields `bson:"freight,omitempty"` // 運費
|
||||||
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // 更新時間
|
CustomFields []CustomFields `bson:"custom_fields,omitempty"` // 自定義屬性
|
||||||
CreatedAt int64 `bson:"created_at" json:"created_at"` // 創建時間
|
SalesCount uint64 `bson:"sales_count" ` // 已賣出數量(相反,減到零就不能在賣)
|
||||||
|
UpdatedAt int64 `bson:"updated_at"` // 更新時間
|
||||||
|
CreatedAt int64 `bson:"created_at"` // 創建時間
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProductItems) CollectionName() string {
|
func (p *ProductItems) CollectionName() string {
|
||||||
return "product"
|
return "product_items"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package product
|
||||||
|
|
||||||
|
type ItemStatus int8
|
||||||
|
|
||||||
|
func (p *ItemStatus) ToString() string {
|
||||||
|
s, _ := StatusToString(*p)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive ItemStatus = 1 + iota // 上架
|
||||||
|
StatusInactive // 下架
|
||||||
|
StatusOutOfStock // 缺貨
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActiveStr = "active"
|
||||||
|
StatusInactiveStr = "inactive"
|
||||||
|
StatusOutOfStockStr = "out_of_stock"
|
||||||
|
)
|
||||||
|
|
||||||
|
var statusToStringMap = map[ItemStatus]string{
|
||||||
|
StatusActive: StatusActiveStr,
|
||||||
|
StatusInactive: StatusInactiveStr,
|
||||||
|
StatusOutOfStock: StatusOutOfStockStr,
|
||||||
|
}
|
||||||
|
|
||||||
|
var stringToStatusMap = map[string]ItemStatus{
|
||||||
|
StatusActiveStr: StatusActive,
|
||||||
|
StatusInactiveStr: StatusInactive,
|
||||||
|
StatusOutOfStockStr: StatusOutOfStock,
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusToString 將 ProductItemStatus 轉換為字串
|
||||||
|
func StatusToString(status ItemStatus) (string, bool) {
|
||||||
|
str, ok := statusToStringMap[status]
|
||||||
|
|
||||||
|
return str, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringToItemStatus 將字串轉換為 ProductItemStatus
|
||||||
|
func StringToItemStatus(statusStr string) (ItemStatus, bool) {
|
||||||
|
status, ok := stringToStatusMap[statusStr]
|
||||||
|
|
||||||
|
return status, ok
|
||||||
|
}
|
|
@ -15,9 +15,14 @@ func (key RedisKey) With(s ...string) RedisKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
GetProductRedisKey RedisKey = "get"
|
GetProductRedisKey RedisKey = "get"
|
||||||
|
GetProductItemRedisKey RedisKey = "get_item"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetProductRK(id string) string {
|
func GetProductRK(id string) string {
|
||||||
return GetProductRedisKey.With(id).ToString()
|
return GetProductRedisKey.With(id).ToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetProductItemRK(id string) string {
|
||||||
|
return GetProductItemRedisKey.With(id).ToString()
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
package repository
|
|
||||||
|
|
||||||
type Index interface{}
|
|
|
@ -19,6 +19,11 @@ type ProductRepository interface {
|
||||||
Transaction(ctx context.Context,
|
Transaction(ctx context.Context,
|
||||||
fn func(sessCtx mongo.SessionContext) (any, error),
|
fn func(sessCtx mongo.SessionContext) (any, error),
|
||||||
opts ...*options.TransactionOptions) error
|
opts ...*options.TransactionOptions) error
|
||||||
|
ProductIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductIndex interface {
|
||||||
|
Index20250317001UP(ctx context.Context) (*mongo.Cursor, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductQueryParams 用於查詢專案的參數
|
// ProductQueryParams 用於查詢專案的參數
|
||||||
|
|
|
@ -1,3 +1,76 @@
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
type ProductItemRepo interface{}
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/entity"
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/product"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProductItemRepository 定義商品相關操作的接口
|
||||||
|
type ProductItemRepository interface {
|
||||||
|
ProductItemIndex
|
||||||
|
ProductItemBasic
|
||||||
|
ProductItemStatistics
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductItemIndex interface {
|
||||||
|
Index20250317001UP(ctx context.Context) (*mongo.Cursor, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductItemBasic 基礎操作
|
||||||
|
type ProductItemBasic interface {
|
||||||
|
// Insert 一次新增多筆
|
||||||
|
Insert(ctx context.Context, item []entity.ProductItems) error
|
||||||
|
// Delete 刪除商品
|
||||||
|
Delete(ctx context.Context, ids []string) error
|
||||||
|
// Update 更新商品
|
||||||
|
Update(ctx context.Context, id string, param *ProductUpdateItem) error
|
||||||
|
// DeleteByReferenceID 刪除某Project 下所有
|
||||||
|
DeleteByReferenceID(ctx context.Context, id string) error
|
||||||
|
// FindByID 根據 ID 查找商品
|
||||||
|
FindByID(ctx context.Context, id string) (*entity.ProductItems, error)
|
||||||
|
// ListProductItem 查找商品列表
|
||||||
|
ListProductItem(ctx context.Context, param ProductItemQueryParams) ([]entity.ProductItems, int64, error)
|
||||||
|
// UpdateStatus 更新品相狀態
|
||||||
|
UpdateStatus(ctx context.Context, id string, status product.ItemStatus) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductItemQueryParams struct {
|
||||||
|
PageSize int64 // 每頁顯示的專案數量
|
||||||
|
PageIndex int64 // 要查詢的頁數
|
||||||
|
ItemID []string // Item 的 ID
|
||||||
|
ReferenceID *string // 對應參照的ID
|
||||||
|
IsFree *bool // 是否為免費品項(贈品)
|
||||||
|
Status *product.ItemStatus // 商品狀態
|
||||||
|
}
|
||||||
|
type ProductUpdateItem struct {
|
||||||
|
Name *string // 名稱
|
||||||
|
Description *string // 描述
|
||||||
|
Price *decimal.Decimal // 價格
|
||||||
|
Stock *int64 // 庫存總數
|
||||||
|
ShortDescription *string // 封面簡短描述
|
||||||
|
IsUnLimit *bool // 是否沒有數量上限
|
||||||
|
IsFree *bool // 是否為免費品項(贈品) -> 開啟就是自訂金額
|
||||||
|
SKU *string // 型號:對應顯示 Item 的 FK
|
||||||
|
TimeSeries *product.TimeSeries // 時段種類
|
||||||
|
Media []entity.Media // 專案動態內容(圖片或者影片)
|
||||||
|
Freight []entity.CustomFields // 運費
|
||||||
|
CustomFields []entity.CustomFields // 自定義屬性
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductItemStatistics interface {
|
||||||
|
// IncSalesCount 更新接單總數 -> 新增
|
||||||
|
IncSalesCount(ctx context.Context, id string, count int64) error
|
||||||
|
// DecSalesCount 減少接單總數 -> 取消
|
||||||
|
DecSalesCount(ctx context.Context, id string, count int64) error
|
||||||
|
// GetSalesCount 取得這item 的接單數
|
||||||
|
GetSalesCount(ctx context.Context, ids []string) ([]ProductItemSalesCount, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductItemSalesCount struct {
|
||||||
|
ID string
|
||||||
|
Count uint64
|
||||||
|
}
|
||||||
|
|
|
@ -256,3 +256,12 @@ func (repo *ProductRepository) Transaction(
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (repo *ProductRepository) Index20250317001UP(ctx context.Context) (*mongo.Cursor, error) {
|
||||||
|
// 等價於 db.account.createIndex({"create_at": 1})
|
||||||
|
repo.DB.PopulateIndex(ctx, "uid", 1, false)
|
||||||
|
repo.DB.PopulateIndex(ctx, "slug", 1, true)
|
||||||
|
repo.DB.PopulateIndex(ctx, "category", 1, false)
|
||||||
|
|
||||||
|
return repo.DB.GetClient().Indexes().List(ctx)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,351 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain"
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/entity"
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/product"
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/repository"
|
||||||
|
mgo "code.30cm.net/digimon/library-go/mongo"
|
||||||
|
"github.com/zeromicro/go-zero/core/stores/cache"
|
||||||
|
"github.com/zeromicro/go-zero/core/stores/mon"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProductItemRepositoryParam struct {
|
||||||
|
Conf *mgo.Conf
|
||||||
|
CacheConf cache.CacheConf
|
||||||
|
DBOpts []mon.Option
|
||||||
|
CacheOpts []cache.Option
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductItemRepository struct {
|
||||||
|
DB mgo.DocumentDBWithCacheUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProductItemRepository(param ProductItemRepositoryParam) repository.ProductItemRepository {
|
||||||
|
e := entity.ProductItems{}
|
||||||
|
documentDB, err := mgo.MustDocumentDBWithCache(
|
||||||
|
param.Conf,
|
||||||
|
e.CollectionName(),
|
||||||
|
param.CacheConf,
|
||||||
|
param.DBOpts,
|
||||||
|
param.CacheOpts,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProductItemRepository{
|
||||||
|
DB: documentDB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) Insert(ctx context.Context, items []entity.ProductItems) error {
|
||||||
|
now := time.Now().UTC().UnixNano()
|
||||||
|
docs := make([]any, 0, len(items))
|
||||||
|
|
||||||
|
for i := range items {
|
||||||
|
if items[i].ID.IsZero() {
|
||||||
|
items[i].ID = primitive.NewObjectID()
|
||||||
|
items[i].CreatedAt = now
|
||||||
|
items[i].UpdatedAt = now
|
||||||
|
}
|
||||||
|
docs = append(docs, items[i])
|
||||||
|
}
|
||||||
|
if len(docs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := repo.DB.GetClient().InsertMany(ctx, docs)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) FindByID(ctx context.Context, id string) (*entity.ProductItems, error) {
|
||||||
|
oid, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
var data entity.ProductItems
|
||||||
|
|
||||||
|
rk := domain.GetProductItemRK(id)
|
||||||
|
err = repo.DB.FindOne(ctx, rk, &data, bson.M{"_id": oid})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return &data, nil
|
||||||
|
case errors.Is(err, mon.ErrNotFound):
|
||||||
|
return nil, ErrNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) Delete(ctx context.Context, ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
objectIDs := make([]primitive.ObjectID, 0, len(ids))
|
||||||
|
cacheKeys := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
oid, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidObjectID
|
||||||
|
}
|
||||||
|
objectIDs = append(objectIDs, oid)
|
||||||
|
cacheKeys = append(cacheKeys, domain.GetProductItemRK(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刪除多筆 Mongo 資料
|
||||||
|
filter := bson.M{"_id": bson.M{"$in": objectIDs}}
|
||||||
|
_, err := repo.DB.GetClient().DeleteMany(ctx, filter)
|
||||||
|
|
||||||
|
// 可選:刪除 Redis 快取(每個 ID 都清除)
|
||||||
|
_ = repo.DB.DelCache(ctx, cacheKeys...)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) Update(ctx context.Context, id string, param *repository.ProductUpdateItem) error {
|
||||||
|
// 將 `id` 轉換為 MongoDB 的 ObjectID
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidObjectID
|
||||||
|
}
|
||||||
|
// 構建更新文檔
|
||||||
|
setFields := bson.M{}
|
||||||
|
// 檢查並添加需要更新的欄位
|
||||||
|
if param.Name != nil {
|
||||||
|
setFields["name"] = *param.Name
|
||||||
|
}
|
||||||
|
if param.Description != nil {
|
||||||
|
setFields["description"] = *param.Description
|
||||||
|
}
|
||||||
|
if param.ShortDescription != nil {
|
||||||
|
setFields["short_description"] = *param.ShortDescription
|
||||||
|
}
|
||||||
|
if param.Price != nil {
|
||||||
|
setFields["price"] = *param.Price
|
||||||
|
}
|
||||||
|
if param.Stock != nil {
|
||||||
|
setFields["stock"] = *param.Stock
|
||||||
|
}
|
||||||
|
if param.IsUnLimit != nil {
|
||||||
|
setFields["is_un_limit"] = *param.IsUnLimit
|
||||||
|
}
|
||||||
|
if param.IsFree != nil {
|
||||||
|
setFields["is_free"] = *param.IsFree
|
||||||
|
}
|
||||||
|
if param.SKU != nil {
|
||||||
|
setFields["sku"] = *param.SKU
|
||||||
|
}
|
||||||
|
if param.TimeSeries != nil {
|
||||||
|
setFields["time_series"] = *param.TimeSeries
|
||||||
|
}
|
||||||
|
if len(param.Media) > 0 {
|
||||||
|
setFields["media"] = param.Media
|
||||||
|
}
|
||||||
|
if param.CustomFields != nil {
|
||||||
|
setFields["custom_fields"] = param.CustomFields
|
||||||
|
}
|
||||||
|
if param.Freight != nil {
|
||||||
|
setFields["freight"] = param.Freight
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果沒有任何需要更新的內容,直接返回
|
||||||
|
if len(setFields) == 0 {
|
||||||
|
return fmt.Errorf("no fields to update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行更新操作
|
||||||
|
filter := bson.M{"_id": objectID}
|
||||||
|
rk := domain.GetProductItemRK(id)
|
||||||
|
_, err = repo.DB.UpdateOne(ctx, rk, filter, bson.M{
|
||||||
|
"$set": setFields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update product item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) DeleteByReferenceID(ctx context.Context, id string) error {
|
||||||
|
// 1. 查詢所有符合 reference_id 的項目,只取 _id 欄位
|
||||||
|
filter := bson.M{"reference_id": id}
|
||||||
|
projection := bson.M{"_id": 1}
|
||||||
|
opts := options.Find().SetProjection(projection)
|
||||||
|
|
||||||
|
var items []entity.ProductItems
|
||||||
|
err := repo.DB.GetClient().Find(ctx, &items, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 刪除這些 ID 對應的 Redis 快取
|
||||||
|
cacheKeys := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
cacheKeys = append(cacheKeys, domain.GetProductItemRK(item.ID.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cacheKeys) > 0 {
|
||||||
|
_ = repo.DB.DelCache(ctx, cacheKeys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 刪除 DB 中的資料
|
||||||
|
delFilter := bson.M{"reference_id": id}
|
||||||
|
_, err = repo.DB.GetClient().DeleteMany(ctx, delFilter)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) ListProductItem(ctx context.Context, param repository.ProductItemQueryParams) ([]entity.ProductItems, int64, error) {
|
||||||
|
// 構建查詢過濾器
|
||||||
|
filter := bson.M{}
|
||||||
|
|
||||||
|
ids := make([]primitive.ObjectID, 0, len(param.ItemID))
|
||||||
|
for _, item := range param.ItemID {
|
||||||
|
oid, err := primitive.ObjectIDFromHex(item)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, oid)
|
||||||
|
}
|
||||||
|
// 添加篩選條件
|
||||||
|
if len(param.ItemID) > 0 {
|
||||||
|
filter["_id"] = bson.M{"$in": ids}
|
||||||
|
}
|
||||||
|
if param.ReferenceID != nil {
|
||||||
|
filter["reference_id"] = *param.ReferenceID
|
||||||
|
}
|
||||||
|
if param.IsFree != nil {
|
||||||
|
filter["is_free"] = *param.IsFree
|
||||||
|
}
|
||||||
|
if param.Status != nil {
|
||||||
|
filter["status"] = *param.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置排序選項
|
||||||
|
opts := options.Find().SetSkip((param.PageIndex - 1) * param.PageSize).SetLimit(param.PageSize)
|
||||||
|
opts.SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||||
|
|
||||||
|
// 查詢符合條件的總數
|
||||||
|
count, err := repo.DB.GetClient().CountDocuments(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行查詢並獲取結果
|
||||||
|
var products []entity.ProductItems
|
||||||
|
err = repo.DB.GetClient().Find(ctx, &products, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return products, count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) UpdateStatus(ctx context.Context, id string, status product.ItemStatus) error {
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := bson.M{"_id": objectID}
|
||||||
|
update := bson.M{"$set": bson.M{"status": status}}
|
||||||
|
|
||||||
|
rk := domain.GetProductItemRK(id)
|
||||||
|
_, err = repo.DB.UpdateOne(ctx, rk, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) IncSalesCount(ctx context.Context, id string, count int64) error {
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := bson.M{"_id": objectID}
|
||||||
|
update := bson.M{"$inc": bson.M{"sales_count": count}}
|
||||||
|
|
||||||
|
rk := domain.GetProductItemRK(id)
|
||||||
|
_, err = repo.DB.UpdateOne(ctx, rk, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrease stock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) DecSalesCount(ctx context.Context, id string, count int64) error {
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := bson.M{"_id": objectID, "sales_count": bson.M{"$gte": count}}
|
||||||
|
update := bson.M{"$inc": bson.M{"sales_count": -count}}
|
||||||
|
|
||||||
|
rk := domain.GetProductItemRK(id)
|
||||||
|
_, err = repo.DB.UpdateOne(ctx, rk, filter, update)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrease stock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) GetSalesCount(ctx context.Context, ids []string) ([]repository.ProductItemSalesCount, error) {
|
||||||
|
objectIDs := make([]primitive.ObjectID, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
objectIDs = append(objectIDs, objectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(objectIDs) == 0 {
|
||||||
|
return nil, ErrInvalidObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]entity.ProductItems, 0, len(ids))
|
||||||
|
filter := bson.M{"_id": bson.M{"$in": objectIDs}}
|
||||||
|
|
||||||
|
err := repo.DB.GetClient().Find(ctx, &result, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stockMap := make([]repository.ProductItemSalesCount, 0, len(result))
|
||||||
|
for _, item := range result {
|
||||||
|
stockMap = append(stockMap, repository.ProductItemSalesCount{
|
||||||
|
ID: item.ID.Hex(),
|
||||||
|
Count: item.SalesCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return stockMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ProductItemRepository) Index20250317001UP(ctx context.Context) (*mongo.Cursor, error) {
|
||||||
|
repo.DB.PopulateIndex(ctx, "reference_id", 1, false)
|
||||||
|
repo.DB.PopulateIndex(ctx, "status", 1, false)
|
||||||
|
|
||||||
|
return repo.DB.GetClient().Indexes().List(ctx)
|
||||||
|
}
|
|
@ -0,0 +1,800 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/entity"
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/product"
|
||||||
|
"code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/repository"
|
||||||
|
mgo "code.30cm.net/digimon/library-go/mongo"
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/zeromicro/go-zero/core/stores/cache"
|
||||||
|
"github.com/zeromicro/go-zero/core/stores/mon"
|
||||||
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetupTestProductItemRepository(db string) (repository.ProductItemRepository, func(), error) {
|
||||||
|
h, p, tearDown, err := startMongoContainer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
s, _ := miniredis.Run()
|
||||||
|
|
||||||
|
conf := &mgo.Conf{
|
||||||
|
Schema: Schema,
|
||||||
|
Host: fmt.Sprintf("%s:%s", h, p),
|
||||||
|
Database: db,
|
||||||
|
MaxStaleness: 300,
|
||||||
|
MaxPoolSize: 100,
|
||||||
|
MinPoolSize: 100,
|
||||||
|
MaxConnIdleTime: 300,
|
||||||
|
Compressors: []string{},
|
||||||
|
EnableStandardReadWriteSplitMode: false,
|
||||||
|
ConnectTimeoutMs: 3000,
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheConf := cache.CacheConf{
|
||||||
|
cache.NodeConf{
|
||||||
|
RedisConf: redis.RedisConf{
|
||||||
|
Host: s.Addr(),
|
||||||
|
Type: redis.NodeType,
|
||||||
|
},
|
||||||
|
Weight: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheOpts := []cache.Option{
|
||||||
|
cache.WithExpiry(1000 * time.Microsecond),
|
||||||
|
cache.WithNotFoundExpiry(1000 * time.Microsecond),
|
||||||
|
}
|
||||||
|
|
||||||
|
param := ProductItemRepositoryParam{
|
||||||
|
Conf: conf,
|
||||||
|
CacheConf: cacheConf,
|
||||||
|
CacheOpts: cacheOpts,
|
||||||
|
DBOpts: []mon.Option{
|
||||||
|
mgo.SetCustomDecimalType(),
|
||||||
|
mgo.InitMongoOptions(*conf),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewProductItemRepository(param)
|
||||||
|
_, _ = repo.Index20250317001UP(context.Background())
|
||||||
|
|
||||||
|
return repo, tearDown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertProductItems(t *testing.T) {
|
||||||
|
model, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
defer tearDown()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 建立多筆 items
|
||||||
|
items := []entity.ProductItems{
|
||||||
|
{
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Item A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Item B",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 呼叫插入
|
||||||
|
err = model.Insert(context.Background(), items)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 驗證插入是否成功(逐筆查詢)
|
||||||
|
for _, item := range items {
|
||||||
|
// 檢查 ID 是否自動填入
|
||||||
|
assert.False(t, item.ID.IsZero(), "ID should be generated")
|
||||||
|
assert.NotZero(t, item.CreatedAt)
|
||||||
|
assert.NotZero(t, item.UpdatedAt)
|
||||||
|
|
||||||
|
// 查詢 DB 確認存在
|
||||||
|
var result *entity.ProductItems
|
||||||
|
result, err = model.FindByID(context.Background(), item.ID.Hex())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, item.Name, result.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧪 空陣列插入測試(不應報錯)
|
||||||
|
t.Run("Insert empty slice", func(t *testing.T) {
|
||||||
|
err := model.Insert(context.Background(), []entity.ProductItems{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteProductItem(t *testing.T) {
|
||||||
|
model, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
defer tearDown()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 建立多筆 items
|
||||||
|
items := []entity.ProductItems{
|
||||||
|
{
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Item A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Item B",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 呼叫插入
|
||||||
|
err = model.Insert(context.Background(), items)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputID string
|
||||||
|
expectErr error
|
||||||
|
checkAfter bool // 是否需要確認資料已刪除
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Delete existing product",
|
||||||
|
inputID: items[0].ID.Hex(),
|
||||||
|
expectErr: nil,
|
||||||
|
checkAfter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete non-existing product",
|
||||||
|
inputID: primitive.NewObjectID().Hex(),
|
||||||
|
expectErr: nil,
|
||||||
|
checkAfter: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid ObjectID format",
|
||||||
|
inputID: "not-an-object-id",
|
||||||
|
expectErr: ErrInvalidObjectID,
|
||||||
|
checkAfter: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := model.Delete(context.Background(), []string{tt.inputID})
|
||||||
|
if tt.expectErr != nil {
|
||||||
|
assert.ErrorIs(t, err, tt.expectErr)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證資料是否真的刪除了(僅當需要)
|
||||||
|
if tt.checkAfter {
|
||||||
|
_, err := model.FindByID(context.Background(), tt.inputID)
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListProductItem(t *testing.T) {
|
||||||
|
model, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
defer tearDown()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
rfcID := primitive.NewObjectID().Hex()
|
||||||
|
// 插入測試資料
|
||||||
|
item1 := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: rfcID,
|
||||||
|
Name: "Item A",
|
||||||
|
IsFree: true,
|
||||||
|
Status: product.StatusActive,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
item2 := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: rfcID,
|
||||||
|
Name: "Item B",
|
||||||
|
IsFree: false,
|
||||||
|
Status: product.StatusInactive,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
item3 := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: rfcID,
|
||||||
|
Name: "Item C",
|
||||||
|
IsFree: true,
|
||||||
|
Status: product.StatusActive,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = model.Insert(context.Background(), []entity.ProductItems{item1, item2, item3})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
params repository.ProductItemQueryParams
|
||||||
|
expectCount int64
|
||||||
|
expectIDs []primitive.ObjectID
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Filter by ReferenceID",
|
||||||
|
params: repository.ProductItemQueryParams{
|
||||||
|
ReferenceID: ptr(rfcID),
|
||||||
|
PageSize: 10,
|
||||||
|
PageIndex: 1,
|
||||||
|
},
|
||||||
|
expectCount: 3,
|
||||||
|
expectIDs: []primitive.ObjectID{item3.ID, item2.ID, item1.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Filter by IsFree = true",
|
||||||
|
params: repository.ProductItemQueryParams{
|
||||||
|
IsFree: ptr(true),
|
||||||
|
PageSize: 10,
|
||||||
|
PageIndex: 1,
|
||||||
|
},
|
||||||
|
expectCount: 2,
|
||||||
|
expectIDs: []primitive.ObjectID{item3.ID, item1.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Filter by Status = 2",
|
||||||
|
params: repository.ProductItemQueryParams{
|
||||||
|
Status: ptr(product.StatusInactive),
|
||||||
|
PageSize: 10,
|
||||||
|
PageIndex: 1,
|
||||||
|
},
|
||||||
|
expectCount: 1,
|
||||||
|
expectIDs: []primitive.ObjectID{item2.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Filter by ItemIDs",
|
||||||
|
params: repository.ProductItemQueryParams{
|
||||||
|
ItemID: []string{item1.ID.Hex(), item2.ID.Hex()},
|
||||||
|
PageSize: 10,
|
||||||
|
PageIndex: 1,
|
||||||
|
},
|
||||||
|
expectCount: 2,
|
||||||
|
expectIDs: []primitive.ObjectID{item2.ID, item1.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pagination works",
|
||||||
|
params: repository.ProductItemQueryParams{
|
||||||
|
PageSize: 1,
|
||||||
|
PageIndex: 2,
|
||||||
|
},
|
||||||
|
expectCount: 3,
|
||||||
|
expectIDs: []primitive.ObjectID{item2.ID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
results, count, err := model.ListProductItem(context.Background(), tt.params)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expectCount, count)
|
||||||
|
|
||||||
|
var gotIDs []primitive.ObjectID
|
||||||
|
for _, r := range results {
|
||||||
|
gotIDs = append(gotIDs, r.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, tt.expectIDs, gotIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteByReferenceID(t *testing.T) {
|
||||||
|
model, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
defer tearDown()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
|
// 插入測試資料
|
||||||
|
refID := primitive.NewObjectID().Hex()
|
||||||
|
item1 := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: refID,
|
||||||
|
Name: "Item A",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
item2 := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: refID,
|
||||||
|
Name: "Item B",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
itemOther := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Should not be deleted",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = model.Insert(ctx, []entity.ProductItems{item1, item2, itemOther})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
referenceID string
|
||||||
|
expectDeleted []primitive.ObjectID
|
||||||
|
expectRemained []primitive.ObjectID
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Delete existing reference_id items",
|
||||||
|
referenceID: refID,
|
||||||
|
expectDeleted: []primitive.ObjectID{item1.ID, item2.ID},
|
||||||
|
expectRemained: []primitive.ObjectID{itemOther.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete non-existent reference_id",
|
||||||
|
referenceID: "no-match-ref",
|
||||||
|
expectDeleted: nil,
|
||||||
|
expectRemained: []primitive.ObjectID{itemOther.ID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := model.DeleteByReferenceID(ctx, tt.referenceID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 檢查指定應被刪除的項目是否真的被刪除
|
||||||
|
for _, id := range tt.expectDeleted {
|
||||||
|
_, err = model.FindByID(ctx, id.Hex())
|
||||||
|
assert.Error(t, err, "Expected item to be deleted: "+id.Hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查不應刪除的項目仍存在
|
||||||
|
for _, id := range tt.expectRemained {
|
||||||
|
_, err = model.FindByID(ctx, id.Hex())
|
||||||
|
assert.NoError(t, err, "Expected item to remain: "+id.Hex())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProductItem(t *testing.T) {
|
||||||
|
model, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
defer tearDown()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 建立一筆測試資料
|
||||||
|
item := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Original Name",
|
||||||
|
Stock: 10,
|
||||||
|
Price: decimal.NewFromInt(500),
|
||||||
|
IsUnLimit: false,
|
||||||
|
IsFree: false,
|
||||||
|
CreatedAt: time.Now().UnixNano(),
|
||||||
|
UpdatedAt: time.Now().UnixNano(),
|
||||||
|
}
|
||||||
|
err = model.Insert(ctx, []entity.ProductItems{item})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
update *repository.ProductUpdateItem
|
||||||
|
expectErr error
|
||||||
|
validate func(t *testing.T, updated *entity.ProductItems)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Update is_un_limit and is_free",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{
|
||||||
|
IsUnLimit: ptr(true),
|
||||||
|
IsFree: ptr(true),
|
||||||
|
},
|
||||||
|
expectErr: nil,
|
||||||
|
validate: func(t *testing.T, updated *entity.ProductItems) {
|
||||||
|
assert.True(t, updated.IsUnLimit)
|
||||||
|
assert.True(t, updated.IsFree)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update SKU and TimeSeries",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{
|
||||||
|
SKU: ptr("SKU-XYZ-001"),
|
||||||
|
TimeSeries: ptr(product.TimeSeriesTenMinutes),
|
||||||
|
},
|
||||||
|
expectErr: nil,
|
||||||
|
validate: func(t *testing.T, updated *entity.ProductItems) {
|
||||||
|
assert.Equal(t, "SKU-XYZ-001", updated.SKU)
|
||||||
|
assert.Equal(t, product.TimeSeriesTenMinutes, updated.TimeSeries)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update Media field",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{
|
||||||
|
Media: []entity.Media{
|
||||||
|
{Sort: 1, Type: "image", URL: "https://example.com/img.jpg"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErr: nil,
|
||||||
|
validate: func(t *testing.T, updated *entity.ProductItems) {
|
||||||
|
assert.Len(t, updated.Media, 1)
|
||||||
|
assert.Equal(t, "image", updated.Media[0].Type)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update CustomFields",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{
|
||||||
|
CustomFields: []entity.CustomFields{
|
||||||
|
{Key: "color", Value: "red"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErr: nil,
|
||||||
|
validate: func(t *testing.T, updated *entity.ProductItems) {
|
||||||
|
assert.Len(t, updated.CustomFields, 1)
|
||||||
|
assert.Equal(t, "color", updated.CustomFields[0].Key)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update Freight",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{
|
||||||
|
Freight: []entity.CustomFields{
|
||||||
|
{
|
||||||
|
Key: "color",
|
||||||
|
Value: "red",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErr: nil,
|
||||||
|
validate: func(t *testing.T, updated *entity.ProductItems) {
|
||||||
|
assert.Equal(t, "color", updated.Freight[0].Key)
|
||||||
|
assert.Equal(t, "red", updated.Freight[0].Value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update name field",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{
|
||||||
|
Name: ptr("Updated Name"),
|
||||||
|
},
|
||||||
|
expectErr: nil,
|
||||||
|
validate: func(t *testing.T, updated *entity.ProductItems) {
|
||||||
|
assert.Equal(t, "Updated Name", updated.Name)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update stock and price",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{
|
||||||
|
Stock: proto.Int64(99),
|
||||||
|
Price: ptr(decimal.NewFromInt(999)),
|
||||||
|
},
|
||||||
|
expectErr: nil,
|
||||||
|
validate: func(t *testing.T, updated *entity.ProductItems) {
|
||||||
|
assert.Equal(t, uint64(99), updated.Stock)
|
||||||
|
assert.Equal(t, "999", updated.Price.String())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid ObjectID",
|
||||||
|
id: "not-an-id",
|
||||||
|
update: &repository.ProductUpdateItem{Name: ptr("Invalid")},
|
||||||
|
expectErr: ErrInvalidObjectID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty update struct",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
update: &repository.ProductUpdateItem{}, // no fields
|
||||||
|
expectErr: fmt.Errorf("no fields to update"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := model.Update(ctx, tt.id, tt.update)
|
||||||
|
if tt.expectErr != nil {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.expectErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := model.FindByID(ctx, item.ID.Hex())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
if tt.validate != nil {
|
||||||
|
tt.validate(t, updated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateStatus_TableDriven(t *testing.T) {
|
||||||
|
repo, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert a sample product item.
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
item := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Test Item",
|
||||||
|
Status: product.StatusInactive, // initial status
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
err = repo.Insert(ctx, []entity.ProductItems{item})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
newStatus product.ItemStatus
|
||||||
|
expectErr error
|
||||||
|
check bool // whether to verify the update in DB
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid update",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
newStatus: product.StatusActive,
|
||||||
|
expectErr: nil,
|
||||||
|
check: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid ObjectID",
|
||||||
|
id: "invalid-id",
|
||||||
|
newStatus: product.StatusActive,
|
||||||
|
expectErr: ErrInvalidObjectID,
|
||||||
|
check: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := repo.UpdateStatus(ctx, tt.id, tt.newStatus)
|
||||||
|
if tt.expectErr != nil {
|
||||||
|
assert.ErrorIs(t, err, tt.expectErr)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If expected to check update, verify that the product's status is updated.
|
||||||
|
if tt.check {
|
||||||
|
updated, err := repo.FindByID(ctx, tt.id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.newStatus, updated.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncSalesCount(t *testing.T) {
|
||||||
|
repo, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert a sample product item with initial SalesCount = 0.
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
item := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Sales Count Test Item",
|
||||||
|
SalesCount: 0,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
err = repo.Insert(ctx, []entity.ProductItems{item})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
count int64
|
||||||
|
expectErr error
|
||||||
|
check bool // whether to verify the updated sales count in the DB.
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Increment sales count by 5",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
count: 5,
|
||||||
|
expectErr: nil,
|
||||||
|
check: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid ObjectID",
|
||||||
|
id: "invalid-id",
|
||||||
|
count: 3,
|
||||||
|
expectErr: ErrInvalidObjectID,
|
||||||
|
check: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := repo.IncSalesCount(ctx, tt.id, tt.count)
|
||||||
|
if tt.expectErr != nil {
|
||||||
|
assert.ErrorIs(t, err, tt.expectErr)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If check is true, verify that the sales_count is updated correctly.
|
||||||
|
if tt.check {
|
||||||
|
updated, err := repo.FindByID(ctx, tt.id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// Since initial SalesCount was 0, after increment it should equal tt.count.
|
||||||
|
assert.Equal(t, uint64(tt.count), updated.SalesCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecSalesCount(t *testing.T) {
|
||||||
|
repo, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
|
// Insert an item with an initial SalesCount of 10.
|
||||||
|
item := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Dec Sales Count Test Item",
|
||||||
|
SalesCount: 10,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert an item with SalesCount equal to 0 (to test underflow behavior).
|
||||||
|
zeroItem := entity.ProductItems{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Zero Sales Count Item",
|
||||||
|
SalesCount: 0,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
err = repo.Insert(ctx, []entity.ProductItems{item, zeroItem})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
decCount int64
|
||||||
|
expectErr error
|
||||||
|
check bool // whether to verify the updated sales count in the DB
|
||||||
|
expected uint64 // expected SalesCount if check is true
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid decrement from 10",
|
||||||
|
id: item.ID.Hex(),
|
||||||
|
decCount: 3,
|
||||||
|
expectErr: nil,
|
||||||
|
check: true,
|
||||||
|
expected: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid ObjectID",
|
||||||
|
id: "invalid-id",
|
||||||
|
decCount: 3,
|
||||||
|
expectErr: ErrInvalidObjectID,
|
||||||
|
check: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Decrement from zero (should not allow underflow)",
|
||||||
|
id: zeroItem.ID.Hex(),
|
||||||
|
decCount: 5,
|
||||||
|
expectErr: nil,
|
||||||
|
check: true,
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := repo.DecSalesCount(ctx, tt.id, tt.decCount)
|
||||||
|
if tt.expectErr != nil {
|
||||||
|
assert.Error(t, err)
|
||||||
|
// Check that the error message contains "failed to decrease stock" (for the underflow case)
|
||||||
|
if tt.expectErr.Error() != ErrInvalidObjectID.Error() {
|
||||||
|
assert.Contains(t, err.Error(), "failed to decrease stock")
|
||||||
|
} else {
|
||||||
|
assert.ErrorIs(t, err, tt.expectErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If expected to check, verify the updated SalesCount.
|
||||||
|
if tt.check {
|
||||||
|
updated, err := repo.FindByID(ctx, tt.id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, updated.SalesCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSalesCount(t *testing.T) {
|
||||||
|
// 取得測試 repository
|
||||||
|
repo, tearDown, err := SetupTestProductItemRepository("testDB")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 預先建立測試資料,設定各項目的 SalesCount 值
|
||||||
|
testItems := []entity.ProductItems{
|
||||||
|
{
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Test Item 1",
|
||||||
|
SalesCount: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ReferenceID: primitive.NewObjectID().Hex(),
|
||||||
|
Name: "Test Item 2",
|
||||||
|
SalesCount: 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入測試資料
|
||||||
|
err = repo.Insert(ctx, testItems)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 建立一組包含有效與無效 ID 的字串陣列
|
||||||
|
validIDs := []string{
|
||||||
|
testItems[0].ID.Hex(),
|
||||||
|
testItems[1].ID.Hex(),
|
||||||
|
}
|
||||||
|
// 在陣列前面加上一個無法轉換的 ID
|
||||||
|
mixedIDs := append([]string{"invalidID"}, validIDs...)
|
||||||
|
|
||||||
|
t.Run("with valid and invalid IDs", func(t *testing.T) {
|
||||||
|
salesCounts, err := repo.GetSalesCount(ctx, mixedIDs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 預期只會回傳有效的兩筆資料
|
||||||
|
require.Len(t, salesCounts, len(validIDs))
|
||||||
|
|
||||||
|
// 驗證每筆資料的 SalesCount 是否正確
|
||||||
|
for _, sc := range salesCounts {
|
||||||
|
switch sc.ID {
|
||||||
|
case testItems[0].ID.Hex():
|
||||||
|
assert.Equal(t, uint64(5), sc.Count)
|
||||||
|
case testItems[1].ID.Hex():
|
||||||
|
assert.Equal(t, uint64(15), sc.Count)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected product item ID: %s", sc.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with all invalid IDs", func(t *testing.T) {
|
||||||
|
salesCounts, err := repo.GetSalesCount(ctx, []string{"badid1", "badid2"})
|
||||||
|
// 因無法轉換成 ObjectID,預期會回傳 ErrInvalidObjectID
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, ErrInvalidObjectID, err)
|
||||||
|
assert.Nil(t, salesCounts)
|
||||||
|
})
|
||||||
|
}
|
|
@ -60,7 +60,7 @@ func SetupTestProductRepository(db string) (repository.ProductRepository, func()
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := NewProductRepository(param)
|
repo := NewProductRepository(param)
|
||||||
//_, _ = repo.Index20241226001UP(context.Background())
|
_, _ = repo.Index20250317001UP(context.Background())
|
||||||
|
|
||||||
return repo, tearDown, nil
|
return repo, tearDown, nil
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue