package services import ( "fmt" "testing" "git.mchus.pro/mchus/priceforge/internal/models" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestUpsertMappingWithOriginalVendor_DeletesEmptyOriginalVendorRow(t *testing.T) { db, err := gorm.Open(sqlite.Open(testSQLiteDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } stmts := []string{ `CREATE TABLE lot ( lot_name TEXT PRIMARY KEY, lot_description TEXT, lot_category TEXT )`, `CREATE TABLE lot_partnumbers ( vendor TEXT NOT NULL DEFAULT '', partnumber TEXT NOT NULL, lot_name TEXT NOT NULL, description TEXT NULL, is_primary_pn INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (partnumber, lot_name) )`, `CREATE TABLE qt_vendor_partnumber_seen ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_type TEXT NOT NULL, vendor TEXT NOT NULL DEFAULT '', partnumber TEXT NOT NULL UNIQUE, description TEXT NULL, last_seen_at DATETIME NOT NULL, is_ignored INTEGER NOT NULL DEFAULT 0, ignored_at DATETIME NULL, ignored_by TEXT NULL, created_at DATETIME NULL, updated_at DATETIME NULL )`, `CREATE TABLE qt_lot_bundles ( bundle_lot_name TEXT PRIMARY KEY, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME NULL, updated_at DATETIME NULL )`, `CREATE TABLE qt_lot_bundle_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, bundle_lot_name TEXT NOT NULL, lot_name TEXT NOT NULL, qty REAL NOT NULL, created_at DATETIME NULL, updated_at DATETIME NULL, UNIQUE(bundle_lot_name, lot_name) )`, } for _, stmt := range stmts { if err := db.Exec(stmt).Error; err != nil { t.Fatalf("exec schema: %v", err) } } if err := db.Create(&models.Lot{LotName: "MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5"}).Error; err != nil { t.Fatalf("insert lot: %v", err) } if err := db.Create(&models.LotPartnumber{ Vendor: "", Partnumber: "7DG9-CTO1WW", LotName: "MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5", }).Error; err != nil { t.Fatalf("insert original mapping: %v", err) } svc := NewVendorMappingService(db) if err := svc.UpsertMappingWithOriginalVendor( "", "Lenovo", "7DG9-CTO1WW", "MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5", "", nil, ); err != nil { t.Fatalf("upsert mapping: %v", err) } var rows []models.LotPartnumber if err := db.Order("vendor").Find(&rows).Error; err != nil { t.Fatalf("list mappings: %v", err) } if len(rows) != 1 { t.Fatalf("got %d mapping rows, want 1", len(rows)) } if rows[0].Vendor != "Lenovo" { t.Fatalf("got vendor %q, want %q", rows[0].Vendor, "Lenovo") } } func TestUpsertMappingWithOriginalVendor_PreservesDescriptionFromEmptyOriginalVendorRow(t *testing.T) { db, err := gorm.Open(sqlite.Open(testSQLiteDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } stmts := []string{ `CREATE TABLE lot ( lot_name TEXT PRIMARY KEY, lot_description TEXT, lot_category TEXT )`, `CREATE TABLE lot_partnumbers ( vendor TEXT NOT NULL DEFAULT '', partnumber TEXT NOT NULL, lot_name TEXT NOT NULL, description TEXT NULL, is_primary_pn INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (partnumber, lot_name) )`, `CREATE TABLE qt_vendor_partnumber_seen ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_type TEXT NOT NULL, vendor TEXT NOT NULL DEFAULT '', partnumber TEXT NOT NULL UNIQUE, description TEXT NULL, last_seen_at DATETIME NOT NULL, is_ignored INTEGER NOT NULL DEFAULT 0, ignored_at DATETIME NULL, ignored_by TEXT NULL, created_at DATETIME NULL, updated_at DATETIME NULL )`, `CREATE TABLE qt_lot_bundles ( bundle_lot_name TEXT PRIMARY KEY, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME NULL, updated_at DATETIME NULL )`, `CREATE TABLE qt_lot_bundle_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, bundle_lot_name TEXT NOT NULL, lot_name TEXT NOT NULL, qty REAL NOT NULL, created_at DATETIME NULL, updated_at DATETIME NULL, UNIQUE(bundle_lot_name, lot_name) )`, } for _, stmt := range stmts { if err := db.Exec(stmt).Error; err != nil { t.Fatalf("exec schema: %v", err) } } const lotName = "MB_INTEL_6.6700/6500(GNR-SP,SRF-SP)_2S_32xDDR5" const wantDescription = "Existing description" if err := db.Create(&models.Lot{LotName: lotName}).Error; err != nil { t.Fatalf("insert lot: %v", err) } if err := db.Create(&models.LotPartnumber{ Vendor: "", Partnumber: "7DG9-CTO1WW", LotName: lotName, Description: stringPtr(wantDescription), }).Error; err != nil { t.Fatalf("insert original mapping: %v", err) } svc := NewVendorMappingService(db) if err := svc.UpsertMappingWithOriginalVendor("", "Lenovo", "7DG9-CTO1WW", lotName, "", nil); err != nil { t.Fatalf("upsert mapping: %v", err) } var row models.LotPartnumber if err := db.Where("vendor = ? AND partnumber = ?", "Lenovo", "7DG9-CTO1WW").First(&row).Error; err != nil { t.Fatalf("load new mapping: %v", err) } if row.Description == nil || *row.Description != wantDescription { got := "" if row.Description != nil { got = *row.Description } t.Fatalf("got description %q, want %q", got, wantDescription) } } func stringPtr(v string) *string { return &v } func testSQLiteDSN(t *testing.T) string { return fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) } func TestPurgeSeenLotNames_RemovesPollutedSeenRowsButKeepsMappedPartnumbers(t *testing.T) { db, err := gorm.Open(sqlite.Open(testSQLiteDSN(t)), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } stmts := []string{ `CREATE TABLE lot ( lot_name TEXT PRIMARY KEY, lot_description TEXT, lot_category TEXT )`, `CREATE TABLE lot_partnumbers ( vendor TEXT NOT NULL DEFAULT '', partnumber TEXT NOT NULL, lot_name TEXT NOT NULL, description TEXT NULL, is_primary_pn INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (partnumber, lot_name) )`, `CREATE TABLE qt_vendor_partnumber_seen ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_type TEXT NOT NULL, vendor TEXT NOT NULL DEFAULT '', partnumber TEXT NOT NULL UNIQUE, description TEXT NULL, last_seen_at DATETIME NOT NULL, is_ignored INTEGER NOT NULL DEFAULT 0, ignored_at DATETIME NULL, ignored_by TEXT NULL, created_at DATETIME NULL, updated_at DATETIME NULL )`, } for _, stmt := range stmts { if err := db.Exec(stmt).Error; err != nil { t.Fatalf("exec schema: %v", err) } } now := "2026-03-07 00:00:00" if err := db.Exec(`INSERT INTO lot (lot_name) VALUES (?), (?)`, "LOT_NAME_1", "LOT_NAME_2").Error; err != nil { t.Fatalf("insert lots: %v", err) } if err := db.Exec(`INSERT INTO lot_partnumbers (vendor, partnumber, lot_name) VALUES (?, ?, ?)`, "", "LOT_NAME_2", "LOT_NAME_2").Error; err != nil { t.Fatalf("insert mapped pn: %v", err) } if err := db.Exec(`INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, last_seen_at) VALUES (?, ?, ?, ?), (?, ?, ?, ?)`, "stock", "", "LOT_NAME_1", now, "stock", "", "LOT_NAME_2", now, ).Error; err != nil { t.Fatalf("insert seen rows: %v", err) } if err := purgeSeenLotNames(db); err != nil { t.Fatalf("purge seen lot names: %v", err) } var rows []models.VendorPartnumberSeen if err := db.Order("partnumber").Find(&rows).Error; err != nil { t.Fatalf("load seen rows: %v", err) } if len(rows) != 1 { t.Fatalf("got %d seen rows, want 1", len(rows)) } if rows[0].Partnumber != "LOT_NAME_2" { t.Fatalf("got partnumber %q, want %q", rows[0].Partnumber, "LOT_NAME_2") } }