package model // Integration tests for InvProduct model against the real dev database. // These tests guard against model/schema drift (e.g. missing or extra columns). // // Run with the dev DB reachable: // go test ./model/ -run TestInvProduct -v // Skip in CI where DB is unavailable: // go test ./model/ -short import ( "context" "database/sql" "fmt" "testing" "time" "github.com/zeromicro/go-zero/core/stores/cache" "github.com/zeromicro/go-zero/core/stores/redis" "github.com/zeromicro/go-zero/core/stores/sqlx" ) const testDSN = "root:muyu2026@tcp(127.0.0.1:13306)/muyu_wms?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai" func newTestProductModel(t *testing.T) InvProductModel { t.Helper() conn := sqlx.NewMysql(testDSN) cacheConf := cache.CacheConf{{RedisConf: redis.RedisConf{Host: "127.0.0.1:6379", Type: "node"}, Weight: 100}} return NewInvProductModel(conn, cacheConf) } func uniqueID(prefix string) string { return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) } // TestInvProductInsert verifies that Insert succeeds against the live DB schema. // This catches model/schema drift like the cost_price column removal. func TestInvProductInsert(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } m := newTestProductModel(t) ctx := context.Background() p := &InvProduct{ ProductId: uniqueID("pid"), ProductName: uniqueID("test_product"), ImageUrl: "", Spec: "100cm", Color: "red", SalesPrice: 99.9, Remark: sql.NullString{String: "test remark", Valid: true}, Status: 1, TenantId: "t_default_001", } result, err := m.Insert(ctx, p) if err != nil { t.Fatalf("Insert failed: %v", err) } id, _ := result.LastInsertId() if id == 0 { t.Fatal("expected non-zero LastInsertId") } t.Logf("inserted product id=%d", id) // cleanup t.Cleanup(func() { _ = m.Delete(ctx, id) }) } // TestInvProductFindOne verifies Insert + FindOne round-trip. func TestInvProductFindOne(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } m := newTestProductModel(t) ctx := context.Background() p := &InvProduct{ ProductId: uniqueID("pid"), ProductName: uniqueID("test_product"), Spec: "200cm", Color: "blue", SalesPrice: 49.5, Status: 1, TenantId: "t_default_001", } res, err := m.Insert(ctx, p) if err != nil { t.Fatalf("Insert: %v", err) } id, _ := res.LastInsertId() t.Cleanup(func() { _ = m.Delete(ctx, id) }) got, err := m.FindOne(ctx, id) if err != nil { t.Fatalf("FindOne: %v", err) } if got.ProductName != p.ProductName { t.Errorf("ProductName: got %q, want %q", got.ProductName, p.ProductName) } if got.SalesPrice != p.SalesPrice { t.Errorf("SalesPrice: got %v, want %v", got.SalesPrice, p.SalesPrice) } } // TestInvProductUpdate verifies Update writes back correctly. func TestInvProductUpdate(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } m := newTestProductModel(t) ctx := context.Background() p := &InvProduct{ ProductId: uniqueID("pid"), ProductName: uniqueID("test_product"), Spec: "50cm", Color: "green", SalesPrice: 10.0, Status: 1, TenantId: "t_default_001", } res, err := m.Insert(ctx, p) if err != nil { t.Fatalf("Insert: %v", err) } id, _ := res.LastInsertId() t.Cleanup(func() { _ = m.Delete(ctx, id) }) got, err := m.FindOne(ctx, id) if err != nil { t.Fatalf("FindOne: %v", err) } got.SalesPrice = 25.0 got.Remark = sql.NullString{String: "updated", Valid: true} if err := m.Update(ctx, got); err != nil { t.Fatalf("Update: %v", err) } updated, err := m.FindOne(ctx, id) if err != nil { t.Fatalf("FindOne after Update: %v", err) } if updated.SalesPrice != 25.0 { t.Errorf("SalesPrice after update: got %v, want 25.0", updated.SalesPrice) } if updated.Remark.String != "updated" { t.Errorf("Remark after update: got %q, want \"updated\"", updated.Remark.String) } } // TestInvProductBulkInsert verifies BulkInsert (used by the import flow). func TestInvProductBulkInsert(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } m := newTestProductModel(t) ctx := context.Background() ids := make([]int64, 0, 3) products := make([]*InvProduct, 3) for i := range products { products[i] = &InvProduct{ ProductId: uniqueID(fmt.Sprintf("bulk_pid_%d", i)), ProductName: uniqueID(fmt.Sprintf("bulk_product_%d", i)), Spec: "10cm", Color: "white", SalesPrice: float64(i+1) * 5.0, Status: 1, TenantId: "t_default_001", } } affected, err := m.BulkInsert(ctx, products) if err != nil { t.Fatalf("BulkInsert: %v", err) } if affected != 3 { t.Errorf("BulkInsert: affected=%d, want 3", affected) } t.Cleanup(func() { for _, p := range products { row, err := m.FindOneByProductId(ctx, p.ProductId) if err == nil { _ = m.Delete(ctx, row.Id) } } _ = ids }) } // TestInvProductSchemaColumns queries information_schema to assert the DB columns // match what the InvProduct struct declares. Fails immediately on any mismatch. func TestInvProductSchemaColumns(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } db, err := sql.Open("mysql", testDSN) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() rows, err := db.QueryContext(context.Background(), "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='inv_product' ORDER BY ORDINAL_POSITION") if err != nil { t.Fatalf("query schema: %v", err) } defer rows.Close() dbCols := map[string]bool{} for rows.Next() { var col string if err := rows.Scan(&col); err != nil { t.Fatalf("scan: %v", err) } dbCols[col] = true } // Columns that the InvProduct struct declares (via db tags). structCols := []string{ "id", "product_id", "product_name", "image_url", "spec", "color", "sales_price", "remark", "status", "tenant_id", "created_at", "updated_at", "deleted_at", } for _, col := range structCols { if !dbCols[col] { t.Errorf("struct declares column %q but it does NOT exist in inv_product", col) } } // Also check for DB columns not in the struct (forward-drift guard). structSet := map[string]bool{} for _, c := range structCols { structSet[c] = true } for col := range dbCols { if !structSet[col] { t.Logf("WARNING: DB column %q exists but is not in the InvProduct struct", col) } } } // TestFindProductSummaryNoPans guards against the GROUP_CONCAT NULL scan bug: // when a product has no pans, the LEFT JOIN produces NULL for pan.position and // GROUP_CONCAT over all-NULL values returns NULL, which cannot be scanned into a // Go string. The fix wraps both GROUP_CONCAT calls with COALESCE(..., ''). func TestFindProductSummaryNoPans(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } m := newTestProductModel(t) ctx := context.Background() p := &InvProduct{ ProductId: uniqueID("pid_nopan"), ProductName: uniqueID("nopan_product"), Spec: "50cm", Color: "red", SalesPrice: 99.0, Status: 1, TenantId: "t_default_001", } res, err := m.Insert(ctx, p) if err != nil { t.Fatalf("Insert: %v", err) } id, _ := res.LastInsertId() t.Cleanup(func() { _ = m.Delete(ctx, id) }) list, err := m.FindProductSummary(ctx, "t_default_001") if err != nil { t.Fatalf("FindProductSummary returned error for product with no pans: %v", err) } var found *ProductSummary for _, s := range list { if s.ProductName == p.ProductName { found = s break } } if found == nil { t.Fatalf("product %q not found in summary (len=%d)", p.ProductName, len(list)) } if found.ColorCount != 1 { t.Errorf("ColorCount: got %d, want 1", found.ColorCount) } if found.TotalPanCount != 0 { t.Errorf("TotalPanCount: got %d, want 0", found.TotalPanCount) } if found.Locations != "" { t.Errorf("Locations: got %q, want empty string", found.Locations) } } // TestFindColorDetailNoPans is the same NULL guard for the color-detail query. func TestFindColorDetailNoPans(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } m := newTestProductModel(t) ctx := context.Background() p := &InvProduct{ ProductId: uniqueID("pid_color"), ProductName: uniqueID("color_nopan_product"), Spec: "80cm", Color: "blue", SalesPrice: 50.0, Status: 1, TenantId: "t_default_001", } res, err := m.Insert(ctx, p) if err != nil { t.Fatalf("Insert: %v", err) } id, _ := res.LastInsertId() t.Cleanup(func() { _ = m.Delete(ctx, id) }) list, err := m.FindColorDetail(ctx, "t_default_001", p.ProductName) if err != nil { t.Fatalf("FindColorDetail returned error for product with no pans: %v", err) } if len(list) != 1 { t.Fatalf("expected 1 color detail row, got %d", len(list)) } if list[0].Color != "blue" { t.Errorf("Color: got %q, want %q", list[0].Color, "blue") } if list[0].Locations != "" { t.Errorf("Locations: got %q, want empty string", list[0].Locations) } }