405 lines
11 KiB
Go
405 lines
11 KiB
Go
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",
|
|
Code: "TEST-KEEP",
|
|
Name: ptrString("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 ptrString(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|