274 lines
8.8 KiB
Go
274 lines
8.8 KiB
Go
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: "Project 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 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 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,
|
|
name TEXT NOT 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 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,
|
|
price_updated_at DATETIME NULL,
|
|
created_at DATETIME
|
|
);`).Error; err != nil {
|
|
t.Fatalf("create qt_configurations: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
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
|
|
}
|