feat: product item

This commit is contained in:
王性驊 2025-03-19 14:45:44 +08:00
parent 6f27ff3bbc
commit 2e2bdecc48
10 changed files with 1312 additions and 23 deletions

View File

@ -8,23 +8,25 @@ 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"` // 是否沒有數量上限
IsFree bool `bson:"is_free"` // 是否為免費品項(贈品) -> 開啟就是自訂金額
Stock uint64 `bson:"stock"` // 庫存總數
Price decimal.Decimal `bson:"price"` // 價格 Price decimal.Decimal `bson:"price"` // 價格
SKU string `bson:"sku"` // 型號:對應顯示 Item 的 FK SKU string `bson:"sku"` // 型號:對應顯示 Item 的 FK
TimeSeries product.TimeSeries `bson:"time_series"` // 時段種類 TimeSeries product.TimeSeries `bson:"time_series"` // 時段種類
Media []Media `bson:"medias,omitempty"` // 專案動態內容(圖片或者影片) Media []Media `bson:"media,omitempty"` // 專案動態內容(圖片或者影片)
AverageRating float64 `bson:"average_rating"` // 綜合評價4.5 顆星) Status product.ItemStatus `bson:"status"` // 商品狀態
AverageRatingUpdateTime int64 `bson:"average_rating_time"` // 更新評價的時間 Freight []CustomFields `bson:"freight,omitempty"` // 運費
Orders uint64 `bson:"total_orders"` // 總接單數 CustomFields []CustomFields `bson:"custom_fields,omitempty"` // 自定義屬性
OrdersUpdateTime int64 `bson:"total_orders_update_time"` // 更新總接單數的時間 SalesCount uint64 `bson:"sales_count" ` // 已賣出數量(相反,減到零就不能在賣)
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // 更新時間 UpdatedAt int64 `bson:"updated_at"` // 更新時間
CreatedAt int64 `bson:"created_at" json:"created_at"` // 創建時間 CreatedAt int64 `bson:"created_at"` // 創建時間
} }
func (p *ProductItems) CollectionName() string { func (p *ProductItems) CollectionName() string {
return "product" return "product_items"
} }

View File

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

View File

@ -16,8 +16,13 @@ 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()
}

View File

@ -1,3 +0,0 @@
package repository
type Index interface{}

View File

@ -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 用於查詢專案的參數

View File

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

View File

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

View File

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

View File

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

View File

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