Local-first runtime cleanup and recovery hardening
This commit is contained in:
@@ -5,7 +5,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
@@ -313,3 +316,280 @@ func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user