package synthesis import ( "context" "testing" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/model" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func newSynthesisServiceForTest(t *testing.T) *service { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite failed: %v", err) } statements := []string{ `CREATE TABLE product_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, is_fragment INTEGER NOT NULL DEFAULT 0, deleted_at DATETIME NULL );`, `CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL DEFAULT 0, name TEXT NOT NULL DEFAULT '', price INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 1, images_json TEXT NOT NULL DEFAULT '', deleted_at DATETIME NULL );`, `CREATE TABLE fragment_synthesis_recipes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', target_product_id INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 1, created_at DATETIME NULL, updated_at DATETIME NULL, deleted_at DATETIME NULL );`, `CREATE TABLE fragment_synthesis_recipe_materials ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipe_id INTEGER NOT NULL DEFAULT 0, fragment_product_id INTEGER NOT NULL DEFAULT 0, required_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME NULL, updated_at DATETIME NULL, deleted_at DATETIME NULL );`, `CREATE TABLE fragment_synthesis_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME NULL, user_id INTEGER NOT NULL DEFAULT 0, recipe_id INTEGER NOT NULL DEFAULT 0, consumed_inventory_ids TEXT NOT NULL DEFAULT '', produced_inventory_id INTEGER NOT NULL DEFAULT 0 );`, `CREATE TABLE user_inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME NULL, updated_at DATETIME NULL, user_id INTEGER NOT NULL DEFAULT 0, product_id INTEGER NOT NULL DEFAULT 0, value_cents INTEGER NOT NULL DEFAULT 0, value_source INTEGER NOT NULL DEFAULT 0, value_snapshot_at DATETIME NULL, order_id INTEGER NOT NULL DEFAULT 0, activity_id INTEGER NOT NULL DEFAULT 0, reward_id INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 1, shipping_no TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '' );`, } for _, stmt := range statements { if err := db.Exec(stmt).Error; err != nil { t.Fatalf("exec schema failed: %v", err) } } return New(mysql.NewTestRepo(db)).(*service) } func TestValidateRecipeProducts_TargetCannotBeFragment(t *testing.T) { svc := newSynthesisServiceForTest(t) ctx := context.Background() if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil { t.Fatalf("seed categories failed: %v", err) } if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (10, 1), (11, 1)").Error; err != nil { t.Fatalf("seed products failed: %v", err) } err := svc.validateRecipeProducts(ctx, 10, []MaterialInput{{FragmentProductID: 11, RequiredCount: 1}}) if err == nil || err.Error() != "target_product_cannot_be_fragment" { t.Fatalf("expected target_product_cannot_be_fragment, got: %v", err) } } func TestValidateRecipeProducts_MaterialMustBeFragment(t *testing.T) { svc := newSynthesisServiceForTest(t) ctx := context.Background() if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil { t.Fatalf("seed categories failed: %v", err) } if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (20, 2), (21, 2)").Error; err != nil { t.Fatalf("seed products failed: %v", err) } err := svc.validateRecipeProducts(ctx, 20, []MaterialInput{{FragmentProductID: 21, RequiredCount: 1}}) want := "material_must_be_fragment:21" if err == nil || err.Error() != want { t.Fatalf("expected %s, got: %v", want, err) } } func TestValidateRecipeProducts_ValidCombination(t *testing.T) { svc := newSynthesisServiceForTest(t) ctx := context.Background() if err := svc.repo.GetDbW().Exec("INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)").Error; err != nil { t.Fatalf("seed categories failed: %v", err) } if err := svc.repo.GetDbW().Exec("INSERT INTO products(id, category_id) VALUES (30, 2), (31, 1)").Error; err != nil { t.Fatalf("seed products failed: %v", err) } err := svc.validateRecipeProducts(ctx, 30, []MaterialInput{{FragmentProductID: 31, RequiredCount: 2}}) if err != nil { t.Fatalf("expected nil error, got: %v", err) } } func TestBatchSynthesizeProducesAllPossibleItems(t *testing.T) { svc := newSynthesisServiceForTest(t) ctx := context.Background() seedBatchSynthesisFixture(t, svc) result, err := svc.BatchSynthesize(ctx, 1001, 1) if err != nil { t.Fatalf("batch synthesize failed: %v", err) } if result.SynthesizedCount != 3 { t.Fatalf("expected 3 syntheses, got %d", result.SynthesizedCount) } if len(result.ProducedInventoryIDs) != 3 { t.Fatalf("expected 3 produced ids, got %d", len(result.ProducedInventoryIDs)) } if result.ConsumedInventoryCount != 9 { t.Fatalf("expected 9 consumed inventory items, got %d", result.ConsumedInventoryCount) } assertInventoryStatusCount(t, svc, 1001, 11, 2, 6) assertInventoryStatusCount(t, svc, 1001, 12, 2, 3) assertInventoryStatusCount(t, svc, 1001, 10, 1, 3) assertSynthesisLogCount(t, svc, 1001, 1, 3) } func TestBatchSynthesizeUsesShortestMaterial(t *testing.T) { svc := newSynthesisServiceForTest(t) ctx := context.Background() seedBatchSynthesisFixture(t, svc) if err := svc.repo.GetDbW().Exec("INSERT INTO user_inventory(user_id, product_id, value_cents, status, remark) VALUES (1001, 12, 0, 1, 'extra_fragment')").Error; err != nil { t.Fatalf("seed extra fragment failed: %v", err) } result, err := svc.BatchSynthesize(ctx, 1001, 1) if err != nil { t.Fatalf("batch synthesize failed: %v", err) } if result.SynthesizedCount != 3 { t.Fatalf("expected shortest material to cap at 3, got %d", result.SynthesizedCount) } assertInventoryStatusCount(t, svc, 1001, 12, 1, 1) } func TestBatchSynthesizeFailsWhenInsufficient(t *testing.T) { svc := newSynthesisServiceForTest(t) ctx := context.Background() seedBatchSynthesisFixture(t, svc) if err := svc.repo.GetDbW().Exec("DELETE FROM user_inventory WHERE user_id = ? AND product_id = ?", 1001, 12).Error; err != nil { t.Fatalf("clear fragments failed: %v", err) } _, err := svc.BatchSynthesize(ctx, 1001, 1) if err == nil || err.Error() != "insufficient_fragments" { t.Fatalf("expected insufficient_fragments, got %v", err) } } func seedBatchSynthesisFixture(t *testing.T, svc *service) { t.Helper() db := svc.repo.GetDbW() statements := []string{ "INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)", "INSERT INTO products(id, category_id, name, price, status) VALUES (10, 2, '目标商品', 1999, 1), (11, 1, '碎片A', 0, 1), (12, 1, '碎片B', 0, 1)", "INSERT INTO fragment_synthesis_recipes(id, name, description, target_product_id, status) VALUES (1, '配方1', '测试配方', 10, 1)", "INSERT INTO fragment_synthesis_recipe_materials(id, recipe_id, fragment_product_id, required_count) VALUES (1, 1, 11, 2), (2, 1, 12, 1)", } for _, stmt := range statements { if err := db.Exec(stmt).Error; err != nil { t.Fatalf("seed fixture failed: %v", err) } } for i := 0; i < 6; i++ { if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 11, ValueCents: 0, Status: 1, Remark: "fragment_a"}).Error; err != nil { t.Fatalf("seed fragment a failed: %v", err) } } for i := 0; i < 3; i++ { if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 12, ValueCents: 0, Status: 1, Remark: "fragment_b"}).Error; err != nil { t.Fatalf("seed fragment b failed: %v", err) } } } func assertInventoryStatusCount(t *testing.T, svc *service, userID, productID int64, status int32, want int64) { t.Helper() var count int64 if err := svc.repo.GetDbR().Model(&model.UserInventory{}).Where("user_id = ? AND product_id = ? AND status = ?", userID, productID, status).Count(&count).Error; err != nil { t.Fatalf("count inventory failed: %v", err) } if count != want { t.Fatalf("expected %d inventory rows for product %d status %d, got %d", want, productID, status, count) } } func assertSynthesisLogCount(t *testing.T, svc *service, userID, recipeID int64, want int64) { t.Helper() var count int64 if err := svc.repo.GetDbR().Model(&model.FragmentSynthesisLogs{}).Where("user_id = ? AND recipe_id = ?", userID, recipeID).Count(&count).Error; err != nil { t.Fatalf("count logs failed: %v", err) } if count != want { t.Fatalf("expected %d synthesis logs, got %d", want, count) } }