From 2e2bdecc4808c2e8ff7bf61e06e6df7b620efb0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 19 Mar 2025 14:45:44 +0800 Subject: [PATCH] feat: product item --- pkg/domain/entity/product_item.go | 36 +- pkg/domain/product/item_status.go | 47 ++ pkg/domain/redis.go | 7 +- pkg/domain/repository/index.go | 3 - pkg/domain/repository/product.go | 5 + pkg/domain/repository/product_item.go | 75 +- pkg/repository/product.go | 9 + pkg/repository/product_item_basic.go | 351 ++++++++++ pkg/repository/product_item_basic_test.go | 800 ++++++++++++++++++++++ pkg/repository/product_test.go | 2 +- 10 files changed, 1312 insertions(+), 23 deletions(-) create mode 100644 pkg/domain/product/item_status.go delete mode 100644 pkg/domain/repository/index.go create mode 100644 pkg/repository/product_item_basic.go create mode 100644 pkg/repository/product_item_basic_test.go diff --git a/pkg/domain/entity/product_item.go b/pkg/domain/entity/product_item.go index b550334..a634485 100644 --- a/pkg/domain/entity/product_item.go +++ b/pkg/domain/entity/product_item.go @@ -7,24 +7,26 @@ import ( ) type ProductItems struct { - ID primitive.ObjectID `bson:"_id,omitempty"` // 專案 ID - ProductID string `bson:"product_id"` // 對應的專案 ID - Name string `bson:"name" json:"name"` // 名稱 - Cover string `bson:"cover"` // 封面 - Description string `bson:"description"` // 描述 - ShortDescription string `bson:"short_description"` // 封面簡短描述 - Price decimal.Decimal `bson:"price"` // 價格 - SKU string `bson:"sku"` // 型號:對應顯示 Item 的 FK - TimeSeries product.TimeSeries `bson:"time_series"` // 時段種類 - Media []Media `bson:"medias,omitempty"` // 專案動態內容(圖片或者影片) - AverageRating float64 `bson:"average_rating"` // 綜合評價(如:4.5 顆星) - AverageRatingUpdateTime int64 `bson:"average_rating_time"` // 更新評價的時間 - Orders uint64 `bson:"total_orders"` // 總接單數 - OrdersUpdateTime int64 `bson:"total_orders_update_time"` // 更新總接單數的時間 - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // 更新時間 - CreatedAt int64 `bson:"created_at" json:"created_at"` // 創建時間 + ID primitive.ObjectID `bson:"_id,omitempty"` // 專案 ID + ReferenceID string `bson:"reference_id"` // 對應的專案 ID + Name string `bson:"name"` // 名稱 + Description string `bson:"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"` // 價格 + SKU string `bson:"sku"` // 型號:對應顯示 Item 的 FK + TimeSeries product.TimeSeries `bson:"time_series"` // 時段種類 + Media []Media `bson:"media,omitempty"` // 專案動態內容(圖片或者影片) + Status product.ItemStatus `bson:"status"` // 商品狀態 + Freight []CustomFields `bson:"freight,omitempty"` // 運費 + CustomFields []CustomFields `bson:"custom_fields,omitempty"` // 自定義屬性 + SalesCount uint64 `bson:"sales_count" ` // 已賣出數量(相反,減到零就不能在賣) + UpdatedAt int64 `bson:"updated_at"` // 更新時間 + CreatedAt int64 `bson:"created_at"` // 創建時間 } func (p *ProductItems) CollectionName() string { - return "product" + return "product_items" } diff --git a/pkg/domain/product/item_status.go b/pkg/domain/product/item_status.go new file mode 100644 index 0000000..e620932 --- /dev/null +++ b/pkg/domain/product/item_status.go @@ -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 +} diff --git a/pkg/domain/redis.go b/pkg/domain/redis.go index 2eaf9d6..b4e6467 100644 --- a/pkg/domain/redis.go +++ b/pkg/domain/redis.go @@ -15,9 +15,14 @@ func (key RedisKey) With(s ...string) RedisKey { } const ( - GetProductRedisKey RedisKey = "get" + GetProductRedisKey RedisKey = "get" + GetProductItemRedisKey RedisKey = "get_item" ) func GetProductRK(id string) string { return GetProductRedisKey.With(id).ToString() } + +func GetProductItemRK(id string) string { + return GetProductItemRedisKey.With(id).ToString() +} diff --git a/pkg/domain/repository/index.go b/pkg/domain/repository/index.go deleted file mode 100644 index 9769d87..0000000 --- a/pkg/domain/repository/index.go +++ /dev/null @@ -1,3 +0,0 @@ -package repository - -type Index interface{} diff --git a/pkg/domain/repository/product.go b/pkg/domain/repository/product.go index ae94ce8..0628e55 100644 --- a/pkg/domain/repository/product.go +++ b/pkg/domain/repository/product.go @@ -19,6 +19,11 @@ type ProductRepository interface { Transaction(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error + ProductIndex +} + +type ProductIndex interface { + Index20250317001UP(ctx context.Context) (*mongo.Cursor, error) } // ProductQueryParams 用於查詢專案的參數 diff --git a/pkg/domain/repository/product_item.go b/pkg/domain/repository/product_item.go index fafb08b..032f113 100644 --- a/pkg/domain/repository/product_item.go +++ b/pkg/domain/repository/product_item.go @@ -1,3 +1,76 @@ 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 +} diff --git a/pkg/repository/product.go b/pkg/repository/product.go index d496c14..19a9953 100644 --- a/pkg/repository/product.go +++ b/pkg/repository/product.go @@ -256,3 +256,12 @@ func (repo *ProductRepository) Transaction( 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) +} diff --git a/pkg/repository/product_item_basic.go b/pkg/repository/product_item_basic.go new file mode 100644 index 0000000..c3f89a6 --- /dev/null +++ b/pkg/repository/product_item_basic.go @@ -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) +} diff --git a/pkg/repository/product_item_basic_test.go b/pkg/repository/product_item_basic_test.go new file mode 100644 index 0000000..ec3b7a6 --- /dev/null +++ b/pkg/repository/product_item_basic_test.go @@ -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) + }) +} diff --git a/pkg/repository/product_test.go b/pkg/repository/product_test.go index 479366c..964c041 100644 --- a/pkg/repository/product_test.go +++ b/pkg/repository/product_test.go @@ -60,7 +60,7 @@ func SetupTestProductRepository(db string) (repository.ProductRepository, func() } repo := NewProductRepository(param) - //_, _ = repo.Index20241226001UP(context.Background()) + _, _ = repo.Index20250317001UP(context.Background()) return repo, tearDown, nil }