package sync_test import ( "encoding/json" "fmt" "path/filepath" "testing" "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/services" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) localSync := syncsvc.NewService(nil, local) projectService := services.NewProjectService(local) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"}) if err != nil { t.Fatalf("create project: %v", err) } cfg, err := configService.Create("tester", &services.CreateConfigRequest{ Name: "Cfg A", Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, ServerCount: 1, ProjectUUID: &project.UUID, }) if err != nil { t.Fatalf("create config: %v", err) } pushService := syncsvc.NewServiceWithDB(serverDB, local) pushed, err := pushService.PushPendingChanges() if err != nil { t.Fatalf("push pending changes: %v", err) } if pushed < 2 { t.Fatalf("expected at least 2 pushed changes, got %d", pushed) } var serverProject models.Project if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil { t.Fatalf("project not pushed to server: %v", err) } var serverCfg models.Configuration if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil { t.Fatalf("configuration not pushed to server: %v", err) } if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID { t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID) } if got := local.CountPendingChanges(); got != 0 { t.Fatalf("expected pending queue to be empty, got %d", got) } } func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) localSync := syncsvc.NewService(nil, local) projectService := services.NewProjectService(local) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) pushService := syncsvc.NewServiceWithDB(serverDB, local) project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"}) if err != nil { t.Fatalf("create project: %v", err) } if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil { t.Fatalf("update project: %v", err) } cfg, err := configService.Create("tester", &services.CreateConfigRequest{ Name: "Cfg linked", Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, ServerCount: 1, ProjectUUID: &project.UUID, }) if err != nil { t.Fatalf("create config: %v", err) } if _, err := pushService.PushPendingChanges(); err != nil { t.Fatalf("push pending changes: %v", err) } var serverProject models.Project if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil { t.Fatalf("project not pushed to server: %v", err) } if serverProject.Name == nil || *serverProject.Name != "Project v2" { t.Fatalf("expected latest project name, got %v", serverProject.Name) } var serverCfg models.Configuration if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil { t.Fatalf("configuration not pushed to server: %v", err) } if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID { t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID) } } func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) localSync := syncsvc.NewService(nil, local) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) pushService := syncsvc.NewServiceWithDB(serverDB, local) created, err := configService.Create("tester", &services.CreateConfigRequest{ Name: "Cfg v1", Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if _, err := pushService.PushPendingChanges(); err != nil { t.Fatalf("initial push: %v", err) } if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{ Name: "Cfg v2", Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}}, ServerCount: 1, ProjectUUID: created.ProjectUUID, }); err != nil { t.Fatalf("update config: %v", err) } localCfg, err := local.GetConfigurationByUUID(created.UUID) if err != nil { t.Fatalf("get local config: %v", err) } cfgSnapshot := localdb.LocalToConfiguration(localCfg) stalePayload := syncsvc.ConfigurationChangePayload{ EventID: "stale-event", IdempotencyKey: fmt.Sprintf("%s:v1:update", created.UUID), ConfigurationUUID: created.UUID, ProjectUUID: cfgSnapshot.ProjectUUID, Operation: "update", CurrentVersionID: "stale-v1", CurrentVersionNo: 1, ConflictPolicy: "last_write_wins", Snapshot: *cfgSnapshot, CreatedAt: time.Now().UTC().Add(-2 * time.Second), } raw, err := json.Marshal(stalePayload) if err != nil { t.Fatalf("marshal stale payload: %v", err) } if err := local.DB().Create(&localdb.PendingChange{ EntityType: "configuration", EntityUUID: created.UUID, Operation: "update", Payload: string(raw), CreatedAt: time.Now().Add(-1 * time.Second), }).Error; err != nil { t.Fatalf("insert stale pending change: %v", err) } if _, err := pushService.PushPendingChanges(); err != nil { t.Fatalf("push pending with stale event: %v", err) } var serverCfg models.Configuration if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil { t.Fatalf("get server config: %v", err) } if serverCfg.Name != "Cfg v2" { t.Fatalf("expected latest name to win, got %q", serverCfg.Name) } if got := local.CountPendingChanges(); got != 0 { t.Fatalf("expected empty pending queue, got %d", got) } } func TestPushPendingChangesCreateIsIdempotent(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) localSync := syncsvc.NewService(nil, local) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) pushService := syncsvc.NewServiceWithDB(serverDB, local) created, err := configService.Create("tester", &services.CreateConfigRequest{ Name: "Cfg Idempotent", Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 500}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if _, err := pushService.PushPendingChanges(); err != nil { t.Fatalf("initial push: %v", err) } localCfg, err := local.GetConfigurationByUUID(created.UUID) if err != nil { t.Fatalf("get local config: %v", err) } currentVersionNo, currentVersionID := getCurrentVersionInfo(t, local, created.UUID, localCfg.CurrentVersionID) cfgSnapshot := localdb.LocalToConfiguration(localCfg) duplicatePayload := syncsvc.ConfigurationChangePayload{ EventID: "duplicate-create-event", IdempotencyKey: fmt.Sprintf("%s:v%d:create", created.UUID, currentVersionNo), ConfigurationUUID: created.UUID, ProjectUUID: cfgSnapshot.ProjectUUID, Operation: "create", CurrentVersionID: currentVersionID, CurrentVersionNo: currentVersionNo, ConflictPolicy: "last_write_wins", Snapshot: *cfgSnapshot, CreatedAt: time.Now().UTC(), } raw, err := json.Marshal(duplicatePayload) if err != nil { t.Fatalf("marshal duplicate payload: %v", err) } if err := local.AddPendingChange("configuration", created.UUID, "create", string(raw)); err != nil { t.Fatalf("add duplicate create pending change: %v", err) } if pushed, err := pushService.PushPendingChanges(); err != nil { t.Fatalf("push duplicate create: %v", err) } else if pushed != 1 { t.Fatalf("expected 1 pushed change for duplicate create, got %d", pushed) } var count int64 if err := serverDB.Model(&models.Configuration{}).Where("uuid = ?", created.UUID).Count(&count).Error; err != nil { t.Fatalf("count server configs: %v", err) } if count != 1 { t.Fatalf("expected one server row after idempotent create, got %d", count) } } func TestPushPendingChangesConfigurationPushesLine(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) localSync := syncsvc.NewService(nil, local) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) pushService := syncsvc.NewServiceWithDB(serverDB, local) created, err := configService.Create("tester", &services.CreateConfigRequest{ Name: "Cfg Line Push", Items: models.ConfigItems{{LotName: "CPU_LINE", Quantity: 1, UnitPrice: 1000}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if created.Line != 10 { t.Fatalf("expected local create line=10, got %d", created.Line) } if _, err := pushService.PushPendingChanges(); err != nil { t.Fatalf("push pending changes: %v", err) } var serverCfg models.Configuration if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil { t.Fatalf("load server config: %v", err) } if serverCfg.Line != 10 { t.Fatalf("expected server line=10 after push, got %d", serverCfg.Line) } } func TestImportConfigurationsToLocalPullsLine(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) cfg := models.Configuration{ UUID: "server-line-config", OwnerUsername: "tester", Name: "Cfg Line Pull", Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}}, ServerCount: 1, Line: 40, } total := cfg.Items.Total() cfg.TotalPrice = &total if err := serverDB.Create(&cfg).Error; err != nil { t.Fatalf("seed server config: %v", err) } svc := syncsvc.NewServiceWithDB(serverDB, local) if _, err := svc.ImportConfigurationsToLocal(); err != nil { t.Fatalf("import configurations to local: %v", err) } localCfg, err := local.GetConfigurationByUUID(cfg.UUID) if err != nil { t.Fatalf("load local config: %v", err) } if localCfg.Line != 40 { t.Fatalf("expected imported line=40, got %d", localCfg.Line) } } func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) localSync := syncsvc.NewService(nil, local) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) pushService := syncsvc.NewServiceWithDB(serverDB, local) created, err := configService.Create("tester", &services.CreateConfigRequest{ Name: "Cfg v1", Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 1, UnitPrice: 700}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{ Name: "Cfg v2", Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 3, UnitPrice: 700}}, ServerCount: 1, ProjectUUID: created.ProjectUUID, }); err != nil { t.Fatalf("update config before first push: %v", err) } pushed, err := pushService.PushPendingChanges() if err != nil { t.Fatalf("push pending changes: %v", err) } if pushed < 1 { t.Fatalf("expected at least one pushed change, got %d", pushed) } var serverCfg models.Configuration if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil { t.Fatalf("configuration not pushed to server: %v", err) } if serverCfg.Name != "Cfg v2" { t.Fatalf("expected latest update to be pushed, got %q", serverCfg.Name) } localCfg, err := local.GetConfigurationByUUID(created.UUID) if err != nil { t.Fatalf("get local config: %v", err) } if localCfg.ServerID == nil || *localCfg.ServerID == 0 { t.Fatalf("expected local configuration to have server_id after push, got %+v", localCfg.ServerID) } } func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB { t.Helper() localPath := filepath.Join(t.TempDir(), "local.db") local, err := localdb.New(localPath) if err != nil { t.Fatalf("init local db: %v", err) } t.Cleanup(func() { _ = local.Close() }) return local } func newServerDBForSyncTest(t *testing.T) *gorm.DB { t.Helper() serverPath := filepath.Join(t.TempDir(), "server.db") db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{}) if err != nil { t.Fatalf("open server sqlite: %v", err) } if err := db.Exec(` CREATE TABLE qt_projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, owner_username TEXT NOT NULL, code TEXT NOT NULL, variant TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, tracker_url TEXT NULL, is_active INTEGER NOT NULL DEFAULT 1, is_system INTEGER NOT NULL DEFAULT 0, created_at DATETIME, updated_at DATETIME );`).Error; err != nil { t.Fatalf("create qt_projects: %v", err) } if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil { t.Fatalf("create qt_projects index: %v", err) } if err := db.Exec(` CREATE TABLE qt_configurations ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, user_id INTEGER NULL, owner_username TEXT NOT NULL, project_uuid TEXT NULL, app_version TEXT NULL, name TEXT NOT NULL, items TEXT NOT NULL, total_price REAL NULL, custom_price REAL NULL, notes TEXT NULL, is_template INTEGER NOT NULL DEFAULT 0, server_count INTEGER NOT NULL DEFAULT 1, server_model TEXT NULL, support_code TEXT NULL, article TEXT NULL, pricelist_id INTEGER NULL, warehouse_pricelist_id INTEGER NULL, competitor_pricelist_id INTEGER NULL, disable_price_refresh INTEGER NOT NULL DEFAULT 0, only_in_stock INTEGER NOT NULL DEFAULT 0, line_no INTEGER NULL, price_updated_at DATETIME NULL, created_at DATETIME );`).Error; err != nil { t.Fatalf("create qt_configurations: %v", err) } return db } func ptrString(value string) *string { return &value } func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) { t.Helper() if currentVersionID == nil || *currentVersionID == "" { t.Fatalf("current version id is empty for %s", configurationUUID) } var version localdb.LocalConfigurationVersion if err := local.DB(). Where("id = ? AND configuration_uuid = ?", *currentVersionID, configurationUUID). First(&version).Error; err != nil { t.Fatalf("get current version info: %v", err) } return version.VersionNo, version.ID }