Fix local pricelist uniqueness and preserve config project on update
This commit is contained in:
@@ -3,6 +3,7 @@ package localdb
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||||
@@ -70,3 +71,57 @@ func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
|||||||
t.Fatalf("expected local migrations to be recorded")
|
t.Fatalf("expected local migrations to be recorded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db")
|
||||||
|
|
||||||
|
local, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&LocalPricelist{
|
||||||
|
ServerID: 10,
|
||||||
|
Version: "2026-02-06-001",
|
||||||
|
Name: "v1",
|
||||||
|
CreatedAt: time.Now().Add(-time.Hour),
|
||||||
|
SyncedAt: time.Now().Add(-time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save first pricelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy
|
||||||
|
ON local_pricelists(version)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create legacy unique version index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix").
|
||||||
|
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete migration record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runLocalMigrations(local.DB()); err != nil {
|
||||||
|
t.Fatalf("rerun local migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&LocalPricelist{
|
||||||
|
ServerID: 11,
|
||||||
|
Version: "2026-02-06-001",
|
||||||
|
Name: "v1-duplicate-version",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save second pricelist with duplicate version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count pricelists: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
uuidpkg "github.com/google/uuid"
|
uuidpkg "github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -557,7 +558,16 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
|||||||
|
|
||||||
// SaveLocalPricelist saves a pricelist to local SQLite
|
// SaveLocalPricelist saves a pricelist to local SQLite
|
||||||
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
||||||
return l.db.Save(pricelist).Error
|
return l.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "server_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
|
"version": pricelist.Version,
|
||||||
|
"name": pricelist.Name,
|
||||||
|
"created_at": pricelist.CreatedAt,
|
||||||
|
"synced_at": pricelist.SyncedAt,
|
||||||
|
"is_used": pricelist.IsUsed,
|
||||||
|
}),
|
||||||
|
}).Create(pricelist).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalPricelists returns all local pricelists
|
// GetLocalPricelists returns all local pricelists
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -47,6 +48,11 @@ var localMigrations = []localMigration{
|
|||||||
name: "Attach existing configurations to latest local pricelist and recalc usage",
|
name: "Attach existing configurations to latest local pricelist and recalc usage",
|
||||||
run: backfillConfigurationPricelists,
|
run: backfillConfigurationPricelists,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_06_pricelist_index_fix",
|
||||||
|
name: "Use unique server_id for local pricelists and allow duplicate versions",
|
||||||
|
run: fixLocalPricelistIndexes,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -237,3 +243,52 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
|||||||
}
|
}
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||||
|
type indexRow struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Unique int `gorm:"column:unique"`
|
||||||
|
}
|
||||||
|
var indexes []indexRow
|
||||||
|
if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
|
||||||
|
return fmt.Errorf("list local_pricelists indexes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, idx := range indexes {
|
||||||
|
if idx.Unique == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
type indexInfoRow struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var info []indexInfoRow
|
||||||
|
if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
|
||||||
|
return fmt.Errorf("load index info for %s: %w", idx.Name, err)
|
||||||
|
}
|
||||||
|
if len(info) != 1 || info[0].Name != "version" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
|
||||||
|
if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
|
||||||
|
return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
|
||||||
|
ON local_pricelists(server_id)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
|
||||||
|
ON local_pricelists(version)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure index local_pricelists(version): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,8 +126,8 @@ func (LocalConfigurationVersion) TableName() string {
|
|||||||
// LocalPricelist stores cached pricelists from server
|
// LocalPricelist stores cached pricelists from server
|
||||||
type LocalPricelist struct {
|
type LocalPricelist struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server
|
||||||
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
Version string `gorm:"not null;index" json:"version"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
|
|||||||
@@ -129,9 +129,12 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return nil, ErrConfigForbidden
|
return nil, ErrConfigForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
projectUUID := localCfg.ProjectUUID
|
||||||
if err != nil {
|
if req.ProjectUUID != nil {
|
||||||
return nil, err
|
projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -418,9 +421,12 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
projectUUID := localCfg.ProjectUUID
|
||||||
if err != nil {
|
if req.ProjectUUID != nil {
|
||||||
return nil, err
|
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -185,6 +185,48 @@ WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user