package localdb import ( "path/filepath" "testing" "time" "github.com/glebarez/sqlite" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/logger" ) func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "legacy_local.db") local, err := New(dbPath) if err != nil { t.Fatalf("open localdb: %v", err) } t.Cleanup(func() { _ = local.Close() }) cfg := &LocalConfiguration{ UUID: "legacy-cfg", Name: "Legacy", Items: LocalConfigItems{}, SyncStatus: "pending", OriginalUsername: "tester", IsActive: true, } if err := local.SaveConfiguration(cfg); err != nil { t.Fatalf("save seed config: %v", err) } if err := local.DB().Where("configuration_uuid = ?", "legacy-cfg").Delete(&LocalConfigurationVersion{}).Error; err != nil { t.Fatalf("delete seed versions: %v", err) } if err := local.DB().Model(&LocalConfiguration{}). Where("uuid = ?", "legacy-cfg"). Update("current_version_id", nil).Error; err != nil { t.Fatalf("clear current_version_id: %v", err) } if err := local.DB().Where("1=1").Delete(&LocalSchemaMigration{}).Error; err != nil { t.Fatalf("clear migration records: %v", err) } if err := runLocalMigrations(local.DB()); err != nil { t.Fatalf("run local migrations manually: %v", err) } migratedCfg, err := local.GetConfigurationByUUID("legacy-cfg") if err != nil { t.Fatalf("get migrated config: %v", err) } if migratedCfg.CurrentVersionID == nil || *migratedCfg.CurrentVersionID == "" { t.Fatalf("expected current_version_id after migration") } if !migratedCfg.IsActive { t.Fatalf("expected migrated config to be active") } var versionCount int64 if err := local.DB().Model(&LocalConfigurationVersion{}). Where("configuration_uuid = ?", "legacy-cfg"). Count(&versionCount).Error; err != nil { t.Fatalf("count versions: %v", err) } if versionCount != 1 { t.Fatalf("expected 1 backfilled version, got %d", versionCount) } var migrationCount int64 if err := local.DB().Model(&LocalSchemaMigration{}).Count(&migrationCount).Error; err != nil { t.Fatalf("count local migrations: %v", err) } if migrationCount == 0 { t.Fatalf("expected local migrations to be recorded") } } func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db") local, err := New(dbPath) if err != nil { t.Fatalf("open localdb: %v", err) } t.Cleanup(func() { _ = local.Close() }) if err := local.SaveLocalPricelist(&LocalPricelist{ ServerID: 10, Version: "2026-02-06-001", Name: "v1", CreatedAt: time.Now().Add(-time.Hour), SyncedAt: time.Now().Add(-time.Hour), }); err != nil { t.Fatalf("save first pricelist: %v", err) } if err := local.DB().Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy ON local_pricelists(version) `).Error; err != nil { t.Fatalf("create legacy unique version index: %v", err) } if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix"). Delete(&LocalSchemaMigration{}).Error; err != nil { t.Fatalf("delete migration record: %v", err) } if err := runLocalMigrations(local.DB()); err != nil { t.Fatalf("rerun local migrations: %v", err) } if err := local.SaveLocalPricelist(&LocalPricelist{ ServerID: 11, Version: "2026-02-06-001", Name: "v1-duplicate-version", CreatedAt: time.Now(), SyncedAt: time.Now(), }); err != nil { t.Fatalf("save second pricelist with duplicate version: %v", err) } var count int64 if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil { t.Fatalf("count pricelists: %v", err) } if count != 2 { t.Fatalf("expected 2 pricelists, got %d", count) } } func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "versions_dedup.db") local, err := New(dbPath) if err != nil { t.Fatalf("open localdb: %v", err) } t.Cleanup(func() { _ = local.Close() }) cfg := &LocalConfiguration{ UUID: "dedup-cfg", Name: "Dedup", Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}}, ServerCount: 1, SyncStatus: "pending", OriginalUsername: "tester", IsActive: true, } if err := local.SaveConfiguration(cfg); err != nil { t.Fatalf("save seed config: %v", err) } baseV1Data, err := BuildConfigurationSnapshot(cfg) if err != nil { t.Fatalf("build v1 snapshot: %v", err) } baseV1 := LocalConfigurationVersion{ ID: uuid.NewString(), ConfigurationUUID: cfg.UUID, VersionNo: 1, Data: baseV1Data, AppVersion: "test", CreatedAt: time.Now(), } if err := local.DB().Create(&baseV1).Error; err != nil { t.Fatalf("insert base v1: %v", err) } if err := local.DB().Model(&LocalConfiguration{}). Where("uuid = ?", cfg.UUID). Update("current_version_id", baseV1.ID).Error; err != nil { t.Fatalf("set current_version_id to v1: %v", err) } v2 := LocalConfigurationVersion{ ID: uuid.NewString(), ConfigurationUUID: cfg.UUID, VersionNo: 2, Data: baseV1.Data, AppVersion: "test", CreatedAt: time.Now().Add(1 * time.Second), } if err := local.DB().Create(&v2).Error; err != nil { t.Fatalf("insert duplicate v2: %v", err) } modified := *cfg modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}} total := modified.Items.Total() modified.TotalPrice = &total modified.UpdatedAt = time.Now() v3Data, err := BuildConfigurationSnapshot(&modified) if err != nil { t.Fatalf("build v3 snapshot: %v", err) } v3 := LocalConfigurationVersion{ ID: uuid.NewString(), ConfigurationUUID: cfg.UUID, VersionNo: 3, Data: v3Data, AppVersion: "test", CreatedAt: time.Now().Add(2 * time.Second), } if err := local.DB().Create(&v3).Error; err != nil { t.Fatalf("insert v3: %v", err) } v4 := LocalConfigurationVersion{ ID: uuid.NewString(), ConfigurationUUID: cfg.UUID, VersionNo: 4, Data: v3Data, AppVersion: "test", CreatedAt: time.Now().Add(3 * time.Second), } if err := local.DB().Create(&v4).Error; err != nil { t.Fatalf("insert duplicate v4: %v", err) } if err := local.DB().Model(&LocalConfiguration{}). Where("uuid = ?", cfg.UUID). Update("current_version_id", v4.ID).Error; err != nil { t.Fatalf("point current_version_id to duplicate v4: %v", err) } if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price"). Delete(&LocalSchemaMigration{}).Error; err != nil { t.Fatalf("delete dedup migration record: %v", err) } if err := runLocalMigrations(local.DB()); err != nil { t.Fatalf("rerun local migrations: %v", err) } var versions []LocalConfigurationVersion if err := local.DB().Where("configuration_uuid = ?", cfg.UUID). Order("version_no ASC"). Find(&versions).Error; err != nil { t.Fatalf("load versions after dedup: %v", err) } if len(versions) != 2 { t.Fatalf("expected 2 versions after dedup, got %d", len(versions)) } if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 { t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo) } var after LocalConfiguration if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil { t.Fatalf("load config after dedup: %v", err) } if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID { t.Fatalf("expected current_version_id to point to kept latest version v3") } } func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db") local, err := New(dbPath) if err != nil { t.Fatalf("open localdb: %v", err) } t.Cleanup(func() { _ = local.Close() }) projectUUID := "project-line" cfg1 := &LocalConfiguration{ UUID: "line-cfg-1", ProjectUUID: &projectUUID, Name: "Cfg 1", Items: LocalConfigItems{}, SyncStatus: "pending", OriginalUsername: "tester", IsActive: true, CreatedAt: time.Now().Add(-2 * time.Hour), } cfg2 := &LocalConfiguration{ UUID: "line-cfg-2", ProjectUUID: &projectUUID, Name: "Cfg 2", Items: LocalConfigItems{}, SyncStatus: "pending", OriginalUsername: "tester", IsActive: true, CreatedAt: time.Now().Add(-1 * time.Hour), } if err := local.SaveConfiguration(cfg1); err != nil { t.Fatalf("save cfg1: %v", err) } if err := local.SaveConfiguration(cfg2); err != nil { t.Fatalf("save cfg2: %v", err) } if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil { t.Fatalf("reset line_no: %v", err) } if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").Delete(&LocalSchemaMigration{}).Error; err != nil { t.Fatalf("delete migration record: %v", err) } if err := runLocalMigrations(local.DB()); err != nil { t.Fatalf("rerun local migrations: %v", err) } var rows []LocalConfiguration if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil { t.Fatalf("load configurations: %v", err) } if len(rows) != 2 { t.Fatalf("expected 2 configurations, got %d", len(rows)) } if rows[0].Line != 10 || rows[1].Line != 20 { t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line) } } func TestRunLocalMigrationsDeduplicatesCanonicalPartnumberCatalog(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "partnumber_catalog_dedup.db") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open sqlite: %v", err) } firstLots := LocalPartnumberBookLots{ {LotName: "LOT-A", Qty: 1}, } secondLots := LocalPartnumberBookLots{ {LotName: "LOT-B", Qty: 2}, } if err := db.Exec(` CREATE TABLE local_partnumber_book_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, partnumber TEXT NOT NULL, lots_json TEXT NOT NULL, description TEXT ) `).Error; err != nil { t.Fatalf("create dirty local_partnumber_book_items: %v", err) } if err := db.Create(&LocalPartnumberBookItem{ Partnumber: "PN-001", LotsJSON: firstLots, Description: "", }).Error; err != nil { t.Fatalf("insert first duplicate row: %v", err) } if err := db.Create(&LocalPartnumberBookItem{ Partnumber: "PN-001", LotsJSON: secondLots, Description: "Canonical description", }).Error; err != nil { t.Fatalf("insert second duplicate row: %v", err) } if err := migrateLocalPartnumberBookCatalog(db); err != nil { t.Fatalf("migrate local partnumber catalog: %v", err) } var items []LocalPartnumberBookItem if err := db.Order("partnumber ASC").Find(&items).Error; err != nil { t.Fatalf("load migrated partnumber items: %v", err) } if len(items) != 1 { t.Fatalf("expected 1 deduplicated item, got %d", len(items)) } if items[0].Partnumber != "PN-001" { t.Fatalf("unexpected partnumber: %s", items[0].Partnumber) } if items[0].Description != "Canonical description" { t.Fatalf("expected merged description, got %q", items[0].Description) } if len(items[0].LotsJSON) != 2 { t.Fatalf("expected merged lots from duplicates, got %d", len(items[0].LotsJSON)) } var duplicateCount int64 if err := db.Model(&LocalPartnumberBookItem{}). Where("partnumber = ?", "PN-001"). Count(&duplicateCount).Error; err != nil { t.Fatalf("count deduplicated partnumber: %v", err) } if duplicateCount != 1 { t.Fatalf("expected unique partnumber row after migration, got %d", duplicateCount) } } func TestSanitizeLocalPartnumberBookCatalogRemovesRowsWithoutPartnumber(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "sanitize_partnumber_catalog.db") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.Exec(` CREATE TABLE local_partnumber_book_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, partnumber TEXT NULL, lots_json TEXT NOT NULL, description TEXT ) `).Error; err != nil { t.Fatalf("create local_partnumber_book_items: %v", err) } if err := db.Exec(` INSERT INTO local_partnumber_book_items (partnumber, lots_json, description) VALUES (NULL, '[]', 'null pn'), ('', '[]', 'empty pn'), ('PN-OK', '[]', 'valid pn') `).Error; err != nil { t.Fatalf("seed local_partnumber_book_items: %v", err) } if err := sanitizeLocalPartnumberBookCatalog(db); err != nil { t.Fatalf("sanitize local partnumber catalog: %v", err) } var items []LocalPartnumberBookItem if err := db.Order("id ASC").Find(&items).Error; err != nil { t.Fatalf("load sanitized items: %v", err) } if len(items) != 1 { t.Fatalf("expected 1 valid item after sanitize, got %d", len(items)) } if items[0].Partnumber != "PN-OK" { t.Fatalf("expected remaining partnumber PN-OK, got %q", items[0].Partnumber) } } func TestNewMigratesLegacyPartnumberBookCatalogBeforeAutoMigrate(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "legacy_partnumber_catalog.db") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.Exec(` CREATE TABLE local_partnumber_book_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, partnumber TEXT NOT NULL UNIQUE, lots_json TEXT NOT NULL, is_primary_pn INTEGER NOT NULL DEFAULT 0, description TEXT ) `).Error; err != nil { t.Fatalf("create legacy local_partnumber_book_items: %v", err) } if err := db.Exec(` INSERT INTO local_partnumber_book_items (partnumber, lots_json, is_primary_pn, description) VALUES ('PN-001', '[{"lot_name":"CPU_A","qty":1}]', 0, 'Legacy row') `).Error; err != nil { t.Fatalf("seed legacy local_partnumber_book_items: %v", err) } local, err := New(dbPath) if err != nil { t.Fatalf("open localdb with legacy catalog: %v", err) } t.Cleanup(func() { _ = local.Close() }) var columns []struct { Name string `gorm:"column:name"` } if err := local.DB().Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&columns).Error; err != nil { t.Fatalf("load local_partnumber_book_items columns: %v", err) } for _, column := range columns { if column.Name == "is_primary_pn" { t.Fatalf("expected legacy is_primary_pn column to be removed before automigrate") } } var items []LocalPartnumberBookItem if err := local.DB().Find(&items).Error; err != nil { t.Fatalf("load migrated local_partnumber_book_items: %v", err) } if len(items) != 1 || items[0].Partnumber != "PN-001" { t.Fatalf("unexpected migrated rows: %#v", items) } } func TestNewRecoversBrokenPartnumberBookCatalogCache(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "broken_partnumber_catalog.db") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.Exec(` CREATE TABLE local_partnumber_book_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, partnumber TEXT NOT NULL UNIQUE, lots_json TEXT NOT NULL, description TEXT ) `).Error; err != nil { t.Fatalf("create broken local_partnumber_book_items: %v", err) } if err := db.Exec(` INSERT INTO local_partnumber_book_items (partnumber, lots_json, description) VALUES ('PN-001', '{not-json}', 'Broken cache row') `).Error; err != nil { t.Fatalf("seed broken local_partnumber_book_items: %v", err) } local, err := New(dbPath) if err != nil { t.Fatalf("open localdb with broken catalog cache: %v", err) } t.Cleanup(func() { _ = local.Close() }) var count int64 if err := local.DB().Model(&LocalPartnumberBookItem{}).Count(&count).Error; err != nil { t.Fatalf("count recovered local_partnumber_book_items: %v", err) } if count != 0 { t.Fatalf("expected empty recovered local_partnumber_book_items, got %d rows", count) } var quarantineTables []struct { Name string `gorm:"column:name"` } if err := local.DB().Raw(` SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'local_partnumber_book_items_broken_%' `).Scan(&quarantineTables).Error; err != nil { t.Fatalf("load quarantine tables: %v", err) } if len(quarantineTables) != 1 { t.Fatalf("expected one quarantined broken catalog table, got %d", len(quarantineTables)) } } func TestCleanupStaleReadOnlyCacheTempTablesDropsShadowTempWhenBaseExists(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "stale_cache_temp.db") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.Exec(` CREATE TABLE local_pricelist_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, pricelist_id INTEGER NOT NULL, partnumber TEXT, brand TEXT NOT NULL DEFAULT '', lot_name TEXT NOT NULL, description TEXT, price REAL NOT NULL DEFAULT 0, quantity INTEGER NOT NULL DEFAULT 0, reserve INTEGER NOT NULL DEFAULT 0, available_qty REAL, partnumbers TEXT, lot_category TEXT, created_at DATETIME, updated_at DATETIME ) `).Error; err != nil { t.Fatalf("create local_pricelist_items: %v", err) } if err := db.Exec(` CREATE TABLE local_pricelist_items__temp ( id INTEGER PRIMARY KEY AUTOINCREMENT, legacy TEXT ) `).Error; err != nil { t.Fatalf("create local_pricelist_items__temp: %v", err) } if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil { t.Fatalf("cleanup stale read-only cache temp tables: %v", err) } if db.Migrator().HasTable("local_pricelist_items__temp") { t.Fatalf("expected stale temp table to be dropped") } if !db.Migrator().HasTable("local_pricelist_items") { t.Fatalf("expected base local_pricelist_items table to remain") } }