package services import ( "encoding/json" "errors" "fmt" "path/filepath" "strings" "sync" "testing" "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" ) func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) { service, local := newLocalConfigServiceForTest(t) created, err := service.Create("tester", &CreateConfigRequest{ Name: "v1", Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil { t.Fatalf("rename config: %v", err) } versions := loadVersions(t, local, created.UUID) if len(versions) != 2 { t.Fatalf("expected 2 versions, got %d", len(versions)) } if versions[0].VersionNo != 1 || versions[1].VersionNo != 2 { t.Fatalf("expected version_no [1,2], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo) } cfg, err := local.GetConfigurationByUUID(created.UUID) if err != nil { t.Fatalf("load local config: %v", err) } if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID != versions[1].ID { t.Fatalf("current_version_id should point to v2") } } func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) { service, local := newLocalConfigServiceForTest(t) created, err := service.Create("tester", &CreateConfigRequest{ Name: "base", Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 2, UnitPrice: 100}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil { t.Fatalf("rename config: %v", err) } if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil { t.Fatalf("rollback to v1: %v", err) } versions := loadVersions(t, local, created.UUID) if len(versions) != 3 { t.Fatalf("expected 3 versions, got %d", len(versions)) } if versions[2].VersionNo != 3 { t.Fatalf("expected v3 as rollback version, got v%d", versions[2].VersionNo) } if versions[2].Data != versions[0].Data { t.Fatalf("expected rollback snapshot data equal to v1 data") } } func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) { service, local := newLocalConfigServiceForTest(t) created, err := service.Create("tester", &CreateConfigRequest{ Name: "initial", Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 1, UnitPrice: 300}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } versionsBefore := loadVersions(t, local, created.UUID) if len(versionsBefore) != 1 { t.Fatalf("expected exactly one version after create") } v1Before := versionsBefore[0] if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil { t.Fatalf("rename config: %v", err) } if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil { t.Fatalf("rollback: %v", err) } versionsAfter := loadVersions(t, local, created.UUID) if len(versionsAfter) != 3 { t.Fatalf("expected 3 versions, got %d", len(versionsAfter)) } v1After := versionsAfter[0] if v1After.ID != v1Before.ID { t.Fatalf("v1 id changed: before=%s after=%s", v1Before.ID, v1After.ID) } if v1After.Data != v1Before.Data { t.Fatalf("v1 data changed") } if !v1After.CreatedAt.Equal(v1Before.CreatedAt) { t.Fatalf("v1 created_at changed") } } func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) { service, local := newLocalConfigServiceForTest(t) created, err := service.Create("tester", &CreateConfigRequest{ Name: "base", Items: models.ConfigItems{{LotName: "NIC_A", Quantity: 1, UnitPrice: 150}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } const workers = 8 start := make(chan struct{}) errCh := make(chan error, workers) var wg sync.WaitGroup for i := 0; i < workers; i++ { i := i wg.Add(1) go func() { defer wg.Done() <-start if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil { errCh <- err } }() } close(start) wg.Wait() close(errCh) for err := range errCh { if err != nil { t.Fatalf("concurrent save failed: %v", err) } } type counts struct { Total int64 DistinctCount int64 Max int } var c counts if err := local.DB().Raw(` SELECT COUNT(*) as total, COUNT(DISTINCT version_no) as distinct_count, COALESCE(MAX(version_no), 0) as max FROM local_configuration_versions WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil { t.Fatalf("query version counts: %v", err) } if c.Total != c.DistinctCount { t.Fatalf("duplicate version numbers detected: total=%d distinct=%d", c.Total, c.DistinctCount) } expected := int64(workers + 1) // initial create version + each successful save if c.Total != expected || c.Max != int(expected) { t.Fatalf("expected total=max=%d, got total=%d max=%d", expected, c.Total, c.Max) } } func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) { service, local := newLocalConfigServiceForTest(t) project := &localdb.LocalProject{ UUID: "project-keep", OwnerUsername: "tester", Name: "Keep Project", IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), SyncStatus: "synced", } if err := local.SaveProject(project); err != nil { t.Fatalf("save project: %v", err) } created, err := service.Create("tester", &CreateConfigRequest{ Name: "cfg", ProjectUUID: &project.UUID, Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID { t.Fatalf("expected created config project_uuid=%s", project.UUID) } updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{ Name: "cfg-updated", Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}, ServerCount: 1, }) if err != nil { t.Fatalf("update config without project_uuid: %v", err) } if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID { t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID) } } func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) { t.Helper() dbPath := filepath.Join(t.TempDir(), "local.db") local, err := localdb.New(dbPath) if err != nil { t.Fatalf("init local db: %v", err) } t.Cleanup(func() { _ = local.Close() }) return NewLocalConfigurationService( local, syncsvc.NewService(nil, local), &QuoteService{}, func() bool { return false }, ), local } func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string) []localdb.LocalConfigurationVersion { t.Helper() var versions []localdb.LocalConfigurationVersion if err := local.DB(). Where("configuration_uuid = ?", configurationUUID). Order("version_no ASC"). Find(&versions).Error; err != nil { t.Fatalf("load versions: %v", err) } return versions } func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error { var lastErr error for i := 0; i < 6; i++ { _, err := service.RenameNoAuth(uuid, name) if err == nil { return nil } lastErr = err if errors.Is(err, ErrVersionConflict) || strings.Contains(err.Error(), "database is locked") { time.Sleep(10 * time.Millisecond) continue } return err } return fmt.Errorf("rename retries exhausted: %w", lastErr) } func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) { service, local := newLocalConfigServiceForTest(t) created, err := service.Create("tester", &CreateConfigRequest{ Name: "initial", Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 1, UnitPrice: 2000}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil { t.Fatalf("rename: %v", err) } if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil { t.Fatalf("rollback: %v", err) } versions := loadVersions(t, local, created.UUID) if len(versions) != 3 { t.Fatalf("expected 3 versions") } var v1 map[string]any var v3 map[string]any if err := json.Unmarshal([]byte(versions[0].Data), &v1); err != nil { t.Fatalf("unmarshal v1: %v", err) } if err := json.Unmarshal([]byte(versions[2].Data), &v3); err != nil { t.Fatalf("unmarshal v3: %v", err) } if fmt.Sprintf("%v", v1["name"]) != fmt.Sprintf("%v", v3["name"]) { t.Fatalf("rollback snapshot differs from v1 snapshot by name") } } func TestDeleteMarksInactiveAndCreatesVersion(t *testing.T) { service, local := newLocalConfigServiceForTest(t) created, err := service.Create("tester", &CreateConfigRequest{ Name: "to-archive", Items: models.ConfigItems{{LotName: "CPU_Z", Quantity: 1, UnitPrice: 500}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if err := service.DeleteNoAuth(created.UUID); err != nil { t.Fatalf("delete no auth: %v", err) } cfg, err := local.GetConfigurationByUUID(created.UUID) if err != nil { t.Fatalf("load archived config: %v", err) } if cfg.IsActive { t.Fatalf("expected config to be inactive after delete") } versions := loadVersions(t, local, created.UUID) if len(versions) != 2 { t.Fatalf("expected 2 versions after archive, got %d", len(versions)) } if versions[1].VersionNo != 2 { t.Fatalf("expected archive to create version 2, got %d", versions[1].VersionNo) } list, total, err := service.ListAll(1, 20) if err != nil { t.Fatalf("list all: %v", err) } if total != int64(len(list)) { t.Fatalf("unexpected total/list mismatch") } if len(list) != 0 { t.Fatalf("expected archived config to be hidden from list") } } func TestReactivateRestoresArchivedConfigurationAndCreatesVersion(t *testing.T) { service, local := newLocalConfigServiceForTest(t) created, err := service.Create("tester", &CreateConfigRequest{ Name: "to-reactivate", Items: models.ConfigItems{{LotName: "CPU_R", Quantity: 1, UnitPrice: 700}}, ServerCount: 1, }) if err != nil { t.Fatalf("create config: %v", err) } if err := service.DeleteNoAuth(created.UUID); err != nil { t.Fatalf("archive config: %v", err) } if _, err := service.ReactivateNoAuth(created.UUID); err != nil { t.Fatalf("reactivate config: %v", err) } cfg, err := local.GetConfigurationByUUID(created.UUID) if err != nil { t.Fatalf("load reactivated config: %v", err) } if !cfg.IsActive { t.Fatalf("expected config to be active after reactivation") } versions := loadVersions(t, local, created.UUID) if len(versions) != 3 { t.Fatalf("expected 3 versions after reactivation, got %d", len(versions)) } if versions[2].VersionNo != 3 { t.Fatalf("expected reactivation version 3, got %d", versions[2].VersionNo) } list, _, err := service.ListAll(1, 20) if err != nil { t.Fatalf("list all after reactivation: %v", err) } if len(list) != 1 { t.Fatalf("expected reactivated config to be visible in list") } }