muyu-apiserver/model/invproductmodel_test.go

452 lines
12 KiB
Go
Raw Permalink Normal View History

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",
ColorNo: "01",
ProductCode: uniqueID("TPC"),
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",
ColorNo: "01",
ProductCode: uniqueID("TPC"),
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",
ColorNo: "01",
ProductCode: uniqueID("TPC"),
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",
ColorNo: fmt.Sprintf("%02d", i+1),
ProductCode: uniqueID(fmt.Sprintf("BTPC%d", i)),
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",
"color_no", "product_code", "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",
ColorNo: "01",
ProductCode: uniqueID("TPC"),
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",
ColorNo: "01",
ProductCode: uniqueID("TPC"),
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)
}
}
func TestFindColorDetailBatchCountDoesNotDuplicateStock(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
conn := sqlx.NewMysql(testDSN)
m := newTestProductModel(t)
panM := newInvProductPanModel(conn)
boltM := newInvProductBoltModel(conn)
batchM := newInvProductBatchModel(conn)
ctx := context.Background()
productId := uniqueID("pcb")
p := &InvProduct{
ProductId: productId,
ProductName: uniqueID("color_batch_product"),
Spec: "120cm",
Color: "natural",
ColorNo: "01",
ProductCode: uniqueID("TPC"),
SalesPrice: 20.0,
Status: 1,
TenantId: "t_default_001",
}
res, err := m.Insert(ctx, p)
if err != nil {
t.Fatalf("Insert product: %v", err)
}
productRowId, _ := res.LastInsertId()
t.Cleanup(func() { _ = m.Delete(ctx, productRowId) })
batchAId := uniqueID("b1")
batchBId := uniqueID("b2")
for _, batch := range []*InvProductBatch{
{BatchId: batchAId, TenantId: p.TenantId, ProductId: productId, BatchNo: p.ProductCode + "-B001", Status: 1},
{BatchId: batchBId, TenantId: p.TenantId, ProductId: productId, BatchNo: p.ProductCode + "-B002", Status: 1},
} {
res, err := batchM.Insert(ctx, batch)
if err != nil {
t.Fatalf("Insert batch: %v", err)
}
id, _ := res.LastInsertId()
t.Cleanup(func() { _ = batchM.Delete(ctx, id) })
}
pans := []*InvProductPan{
{PanId: uniqueID("pa"), ProductId: productId, BatchId: batchAId, Name: "A", Position: "A-01", SortOrder: 1, TenantId: p.TenantId},
{PanId: uniqueID("pb"), ProductId: productId, BatchId: batchBId, Name: "B", Position: "B-02", SortOrder: 2, TenantId: p.TenantId},
}
for _, pan := range pans {
res, err := panM.Insert(ctx, pan)
if err != nil {
t.Fatalf("Insert pan: %v", err)
}
id, _ := res.LastInsertId()
t.Cleanup(func() { _ = panM.Delete(ctx, id) })
}
for _, bolt := range []*InvProductBolt{
{BoltId: uniqueID("ba1"), PanId: pans[0].PanId, LengthM: 10.5, Color: p.Color, SortOrder: 1, TenantId: p.TenantId},
{BoltId: uniqueID("ba2"), PanId: pans[0].PanId, LengthM: 11.5, Color: p.Color, SortOrder: 2, TenantId: p.TenantId},
{BoltId: uniqueID("bb1"), PanId: pans[1].PanId, LengthM: 12.0, Color: p.Color, SortOrder: 1, TenantId: p.TenantId},
} {
res, err := boltM.Insert(ctx, bolt)
if err != nil {
t.Fatalf("Insert bolt: %v", err)
}
id, _ := res.LastInsertId()
t.Cleanup(func() { _ = boltM.Delete(ctx, id) })
}
list, err := m.FindColorDetail(ctx, p.TenantId, p.ProductName)
if err != nil {
t.Fatalf("FindColorDetail: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 color detail row, got %d", len(list))
}
got := list[0]
if got.PanCount != 2 {
t.Errorf("PanCount: got %d, want 2", got.PanCount)
}
if got.BoltCount != 3 {
t.Errorf("BoltCount: got %d, want 3", got.BoltCount)
}
if got.BatchCount != 2 {
t.Errorf("BatchCount: got %d, want 2", got.BatchCount)
}
if got.TotalLengthM != 34.0 {
t.Errorf("TotalLengthM: got %.2f, want 34.00", got.TotalLengthM)
}
}