feat: Phase 2 - Master Data Versioning & Fresh Installation

Implements master data versioning system with backward compatibility
transformers and fresh installation orchestrator.

Components Added:
- deployment/masterdata/: Versioned export/import with transformers
  * export.go: ExportData structure (v1.0) with JSON I/O
  * transformers.go: ImportTransformer registry for version migrations
  * sync.go: MasterDataSync orchestrator with dependency-ordered imports
  * Tests: 8 tests covering export, transform, and sync operations

- deployment/install/: Fresh installation system
  * installer.go: NewInstallation orchestrator with 4-step process
  * createDatabaseSchema(): GORM AutoMigrate integration
  * initializeVersionTracking(): Version table setup
  * importMasterData(): Delegates to gsmaster functions
  * createAdmin(): Admin user creation with MD5 hashing
  * Tests: 7 tests covering installation flow and validation

Features:
- Export versioning (CurrentExportVersion = "1.0")
- Backward compatibility via transformer chain
- Dry-run capability for safe testing
- Dependency-ordered master data imports
- Admin user creation with role assignment

Integration:
- Uses models.MigrateStructure() for database-agnostic schema
- Delegates to gsmaster.ImportSources/Skills/Equipment/etc.
- Compatible with existing user authentication (MD5 hashing)

Testing:
 38 tests passing across all deployment packages
 backup: 6 tests
 install: 7 tests
 masterdata: 8 tests
 migrations: 11 tests
 version: 6 tests

Documentation:
- PHASE_2_COMPLETE.md: Comprehensive summary and API examples

Ready for Phase 3: CLI Deployment Tool
This commit is contained in:
2026-01-16 16:03:34 +01:00
parent ccf0e9b3f2
commit 33633f1d46
9 changed files with 1064 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
# Phase 2: Master Data & Compatibility - COMPLETE ✅
**Completion Date:** 2026-01-16
**Status:** All tests passing (38 total tests)
**Branch:** deployment_procedure
## Overview
Phase 2 implements master data versioning and fresh installation capabilities for Bamort deployment system.
## Implemented Components
### 1. Master Data Export/Import Versioning (`deployment/masterdata/`)
#### Features Implemented
- **Versioned Export Structure** (`export.go`)
- `CurrentExportVersion = "1.0"` constant
- `ExportData` structure with metadata (version, backend version, timestamp, game system)
- `ReadExportFile()` - reads JSON, defaults to v1.0 if no version specified
- `WriteExportFile()` - writes formatted JSON exports
- **Backward Compatibility Transformers** (`transformers.go`)
- `ImportTransformer` interface for version transformation
- `TransformToCurrentVersion()` - applies transformers sequentially
- `RegisterTransformer()` - dynamic transformer registration
- Ready for V1ToV2 transformers when format changes
- **Master Data Synchronization** (`sync.go`)
- `MasterDataSync` orchestrator for dependency-ordered imports
- Dry-run capability for testing without database changes
- Import order: Sources → Classes → Categories → Skills → Equipment → Learning Costs
- Delegates to existing gsmaster functions (ImportSources, ImportSkills, etc.)
#### Test Coverage
```
✅ TestReadExportFile - roundtrip JSON export/import
✅ TestReadExportFile_NoVersion - defaults to v1.0
✅ TestWriteExportFile - JSON formatting
✅ TestTransformToCurrentVersion_AlreadyCurrent - no-op for current version
✅ TestRegisterTransformer - transformer registry
✅ TestNewMasterDataSync - initialization
✅ TestSyncAll_DryRun - dry-run mode
✅ TestSyncAll_InvalidDirectory - error handling
```
**Test Results:** 8 passing
### 2. Fresh Installation System (`deployment/install/`)
#### Features Implemented
- **New Installation Orchestrator** (`installer.go`)
- `NewInstallation` struct with configurable options
- `Initialize()` - 4-step installation process
- `createDatabaseSchema()` - GORM AutoMigrate for all tables
- `initializeVersionTracking()` - creates version tables and records
- `importMasterData()` - imports initial game system data
- `createAdmin()` - optional admin user creation with MD5 password hashing
- **Installation Steps**
1. Create database schema using GORM
2. Initialize version tracking (schema_version + migration_history tables)
3. Import master data from specified directory
4. Optionally create admin user
- **Admin User Creation**
- Uses MD5 password hashing (matching existing user/handlers.go)
- Sets `Role = RoleAdmin` instead of deprecated `IsAdmin` field
- Detects existing admin users and skips creation
#### Test Coverage
```
✅ TestNewInstaller - initialization
✅ TestInitialize_MinimalSetup - full installation flow (fails on missing master data)
✅ TestInitializeVersionTracking - version table creation
✅ TestCreateAdmin - admin user creation with MD5 hash
✅ TestCreateAdmin_AlreadyExists - skip if already exists
✅ TestCreateAdmin_NoPassword - validation
✅ TestCreateDatabaseSchema - table creation
```
**Test Results:** 7 passing
## Integration with Existing Systems
### GORM AutoMigrate
- Uses `models.MigrateStructure(db)` for schema creation
- Database-agnostic (works with SQLite and MariaDB)
### GSMaster Integration
- `MasterDataSync` delegates to existing gsmaster functions:
- `ImportSources()`
- `ImportCharacterClasses()`
- `ImportSkillCategories()`
- `ImportSkillDifficulties()`
- `ImportSpellSchools()`
- `ImportSkills()`
- `ImportWeaponSkills()`
- `ImportSpells()`
- `ImportEquipment()`
- `ImportSkillImprovementCosts()`
### User System Integration
- Admin creation uses `user.User` struct
- Password hashing via `crypto/md5` (matching Register handler)
- Role assignment via `user.RoleAdmin` constant
## Test Execution Summary
### All Deployment Tests
```bash
go test -v ./deployment/...
```
**Results:**
- ✅ backup: 6 tests passing
- ✅ install: 7 tests passing
- ✅ masterdata: 8 tests passing
- ✅ migrations: 11 tests passing
- ✅ version: 6 tests passing
**Total: 38 tests passing, 0 failures**
## File Structure
```
backend/deployment/
├── backup/
│ ├── backup.go # Backup service (Phase 1)
│ └── backup_test.go # 6 tests
├── install/ # NEW in Phase 2
│ ├── installer.go # Fresh installation orchestrator
│ └── installer_test.go # 7 tests
├── masterdata/ # NEW in Phase 2
│ ├── export.go # Versioned export structure
│ ├── transformers.go # Backward compatibility
│ ├── sync.go # Master data synchronization
│ ├── export_test.go # 5 tests
│ └── sync_test.go # 3 tests
├── migrations/
│ ├── migration.go # Migration structure (Phase 1)
│ ├── runner.go # Migration runner with dry-run (Phase 1)
│ ├── gorm_fallback.go # GORM AutoMigrate integration (Phase 1)
│ └── runner_test.go # 11 tests
└── version/
├── version.go # Version compatibility checking (Phase 1)
└── version_test.go # 6 tests
```
## API Examples
### Fresh Installation
```go
installer := install.NewInstaller(database.DB)
installer.MasterDataPath = "./data/masterdata"
installer.CreateAdminUser = true
installer.AdminUsername = "admin"
installer.AdminPassword = "secure-password"
installer.GameSystem = "midgard"
result, err := installer.Initialize()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Installation complete: %s (took %v)\n",
result.Version, result.ExecutionTime)
```
### Master Data Synchronization
```go
sync := masterdata.NewMasterDataSync(database.DB, "./data/masterdata")
sync.DryRun = true // Test without changes
sync.Verbose = true
if err := sync.SyncAll(); err != nil {
log.Fatal(err)
}
```
### Export Versioning
```go
// Write versioned export
data := &masterdata.ExportData{
ExportVersion: masterdata.CurrentExportVersion,
BackendVersion: config.GetVersion(),
Timestamp: time.Now(),
GameSystem: "midgard",
Data: exportedData,
}
if err := masterdata.WriteExportFile("export.json", data); err != nil {
log.Fatal(err)
}
// Read and transform old exports
imported, err := masterdata.ReadExportFile("old_export.json")
if err != nil {
log.Fatal(err)
}
// Automatically transforms from v1.0 to current version
current, err := masterdata.TransformToCurrentVersion(imported)
```
## Design Decisions
### 1. Version Defaulting
- Exports without version metadata default to "1.0"
- Ensures backward compatibility with existing exports
- Avoids breaking changes when adding versioning
### 2. Dependency-Ordered Imports
- Master data imported in dependency order
- Sources → Classes → Categories → Skills → Equipment
- Prevents foreign key constraint violations
### 3. Transformer Registry Pattern
- Allows adding transformers without modifying core code
- Supports chaining multiple transformations (v1→v2→v3)
- Only applies transformers when needed (current version = no-op)
### 4. Admin User Hashing
- Uses MD5 matching existing `user/handlers.go` Register function
- **Note:** MD5 is cryptographically weak, recommend upgrading to bcrypt
- Maintains compatibility with current authentication system
### 5. Installation Validation
- Each step validated before proceeding
- Detailed error messages with context
- Installation result includes timing and status
## Known Limitations
1. **Password Security:** Admin user creation uses MD5 hashing (matches existing system but should be upgraded to bcrypt)
2. **Master Data Path:** Hardcoded to `./masterdata` by default (configurable via `MasterDataPath` property)
3. **No Rollback:** Installation is not transactional - partial failure may leave database in inconsistent state
4. **Transformer Chain:** Currently no transformers registered (will add when format changes)
## Next Steps (Phase 3)
Phase 2 is complete. Ready to proceed with:
1. **Phase 3: CLI Deployment Tool** - Command-line interface for deployment operations
2. **Phase 4: API Endpoints** - REST endpoints for migration status and execution
3. **Phase 5: Frontend Banner** - User notification for pending updates
4. **Phase 6: Documentation** - Deployment procedures and runbook
## Breaking Changes
None - all changes are additive and backward compatible.
## Migration Path
For production systems:
1. Pull latest code from `deployment_procedure` branch
2. Run migrations to create version tables: `go run cmd/main.go --migrate`
3. (Future) Use CLI tool to check for pending migrations
4. (Future) Apply migrations via CLI or API endpoint
For new installations:
1. Use `install.NewInstaller()` instead of manual schema creation
2. Specify master data path and admin credentials
3. Call `Initialize()` to set up complete system
---
**Phase 2 Status:** ✅ COMPLETE
**Test Coverage:** 38 tests passing
**Ready for:** Phase 3 (CLI Tool Implementation)
+232
View File
@@ -0,0 +1,232 @@
package install
import (
"bamort/config"
"bamort/deployment/masterdata"
"bamort/deployment/migrations"
"bamort/logger"
"bamort/models"
"bamort/user"
"crypto/md5"
"fmt"
"time"
"gorm.io/gorm"
)
// NewInstallation handles fresh database installation
type NewInstallation struct {
DB *gorm.DB
MasterDataPath string
CreateAdminUser bool
AdminUsername string
AdminPassword string
GameSystem string
}
// InstallationResult contains the result of the installation
type InstallationResult struct {
Success bool
Version string
TablesCreated int
AdminCreated bool
MasterDataOK bool
ExecutionTime time.Duration
Errors []string
}
// NewInstaller creates a new installation instance
func NewInstaller(db *gorm.DB) *NewInstallation {
return &NewInstallation{
DB: db,
MasterDataPath: "./masterdata",
CreateAdminUser: false,
GameSystem: "midgard",
}
}
// Initialize performs a fresh installation
func (n *NewInstallation) Initialize() (*InstallationResult, error) {
startTime := time.Now()
result := &InstallationResult{
Version: config.GetVersion(),
}
logger.Info("Initializing new Bamort installation...")
logger.Info("Backend version: %s", result.Version)
// Step 1: Create database schema using GORM
logger.Info("Step 1/4: Creating database schema...")
if err := n.createDatabaseSchema(); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Schema creation failed: %v", err))
return result, fmt.Errorf("schema creation failed: %w", err)
}
logger.Info("✓ Database schema created successfully")
// Step 2: Initialize version tracking
logger.Info("Step 2/4: Initializing version tracking...")
if err := n.initializeVersionTracking(); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Version tracking failed: %v", err))
return result, fmt.Errorf("version tracking failed: %w", err)
}
logger.Info("✓ Version tracking initialized (DB version: %s)", config.GetVersion())
// Step 3: Import master data
logger.Info("Step 3/4: Importing master data from %s...", n.MasterDataPath)
if err := n.importMasterData(); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Master data import failed: %v", err))
return result, fmt.Errorf("master data import failed: %w", err)
}
result.MasterDataOK = true
logger.Info("✓ Master data imported successfully")
// Step 4: Create admin user if requested
if n.CreateAdminUser {
logger.Info("Step 4/4: Creating admin user '%s'...", n.AdminUsername)
if err := n.createAdmin(); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Admin creation failed: %v", err))
return result, fmt.Errorf("admin creation failed: %w", err)
}
result.AdminCreated = true
logger.Info("✓ Admin user created successfully")
} else {
logger.Info("Step 4/4: Skipping admin user creation (not requested)")
}
result.Success = true
result.ExecutionTime = time.Since(startTime)
logger.Info("═══════════════════════════════════════════════════")
logger.Info("Installation completed successfully!")
logger.Info("Version: %s", result.Version)
logger.Info("Execution time: %v", result.ExecutionTime)
logger.Info("═══════════════════════════════════════════════════")
return result, nil
}
// createDatabaseSchema creates all tables using GORM AutoMigrate
func (n *NewInstallation) createDatabaseSchema() error {
logger.Debug("Running GORM AutoMigrate for all models...")
if err := models.MigrateStructure(n.DB); err != nil {
return fmt.Errorf("GORM AutoMigrate failed: %w", err)
}
logger.Debug("All tables created successfully")
return nil
}
// initializeVersionTracking creates version tables and records initial version
func (n *NewInstallation) initializeVersionTracking() error {
// Get the first migration (creates version tables)
if len(migrations.AllMigrations) == 0 {
return fmt.Errorf("no migrations available")
}
firstMigration := migrations.AllMigrations[0]
logger.Debug("Applying initial migration: %s", firstMigration.Description)
// Create tables using the migration's DataFunc (GORM-based)
if firstMigration.DataFunc != nil {
if err := firstMigration.DataFunc(n.DB); err != nil {
return fmt.Errorf("failed to create version tables: %w", err)
}
} else {
// Fallback: execute SQL if no DataFunc
for _, sql := range firstMigration.UpSQL {
if err := n.DB.Exec(sql).Error; err != nil {
return fmt.Errorf("failed to execute SQL: %w", err)
}
}
}
// Record initial version (all migrations are considered "pre-applied")
latestMigration := migrations.GetLatestMigration()
if latestMigration == nil {
return fmt.Errorf("no migrations available")
}
version := map[string]interface{}{
"version": latestMigration.Version,
"migration_number": latestMigration.Number,
"applied_at": time.Now(),
"backend_version": config.GetVersion(),
"description": "Initial installation",
}
if err := n.DB.Table("schema_version").Create(version).Error; err != nil {
return fmt.Errorf("failed to record version: %w", err)
}
// Record migration history for all migrations (as pre-applied)
for _, m := range migrations.AllMigrations {
history := map[string]interface{}{
"migration_number": m.Number,
"version": m.Version,
"description": m.Description,
"applied_at": time.Now(),
"applied_by": "installer",
"execution_time_ms": 0,
"success": true,
"rollback_available": len(m.DownSQL) > 0,
}
if err := n.DB.Table("migration_history").Create(history).Error; err != nil {
return fmt.Errorf("failed to record migration history: %w", err)
}
}
logger.Debug("Version tracking initialized with version %s (migration %d)",
latestMigration.Version, latestMigration.Number)
return nil
}
// importMasterData imports all master data using MasterDataSync
func (n *NewInstallation) importMasterData() error {
sync := masterdata.NewMasterDataSync(n.DB, n.MasterDataPath)
sync.Verbose = true
if err := sync.SyncAll(); err != nil {
return err
}
return nil
}
// createAdmin creates the admin user
func (n *NewInstallation) createAdmin() error {
if n.AdminUsername == "" {
return fmt.Errorf("admin username not specified")
}
if n.AdminPassword == "" {
return fmt.Errorf("admin password not specified")
}
// Check if user already exists
var existing user.User
if err := n.DB.Where("username = ?", n.AdminUsername).First(&existing).Error; err == nil {
logger.Warn("Admin user '%s' already exists, skipping creation", n.AdminUsername)
return nil
}
// Create new admin user with MD5 password hash (matching user/handlers.go)
admin := &user.User{
Username: n.AdminUsername,
Email: n.AdminUsername + "@localhost",
Role: user.RoleAdmin,
}
// Hash password using MD5 (same as Register handler)
hashedPassword := fmt.Sprintf("%x", md5.Sum([]byte(n.AdminPassword)))
admin.PasswordHash = hashedPassword
if err := n.DB.Create(admin).Error; err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
logger.Debug("Admin user '%s' created with ID %d", admin.Username, admin.UserID)
return nil
}
@@ -0,0 +1,138 @@
package install
import (
"bamort/database"
"testing"
"github.com/stretchr/testify/assert"
)
func setupTestDB(t *testing.T) {
database.SetupTestDB()
t.Cleanup(func() {
database.ResetTestDB()
})
}
func TestNewInstaller(t *testing.T) {
setupTestDB(t)
installer := NewInstaller(database.DB)
assert.NotNil(t, installer)
assert.NotNil(t, installer.DB)
assert.Equal(t, "./masterdata", installer.MasterDataPath)
assert.False(t, installer.CreateAdminUser)
assert.Equal(t, "midgard", installer.GameSystem)
}
func TestInitialize_MinimalSetup(t *testing.T) {
setupTestDB(t)
installer := NewInstaller(database.DB)
installer.MasterDataPath = "./testdata" // Use non-existent path for test
// Should fail because master data path doesn't exist
result, err := installer.Initialize()
// Check that we got to the master data import step before failing
assert.Error(t, err)
assert.NotNil(t, result)
assert.False(t, result.Success)
assert.Contains(t, err.Error(), "master data")
}
func TestInitializeVersionTracking(t *testing.T) {
setupTestDB(t)
installer := NewInstaller(database.DB)
err := installer.initializeVersionTracking()
assert.NoError(t, err)
// Verify version table was created and populated
var version struct {
Version string
MigrationNumber int
Description string
}
err = installer.DB.Table("schema_version").
Order("id DESC").
Limit(1).
Scan(&version).Error
assert.NoError(t, err)
assert.NotEmpty(t, version.Version)
assert.Greater(t, version.MigrationNumber, 0)
assert.Equal(t, "Initial installation", version.Description)
}
func TestCreateAdmin(t *testing.T) {
setupTestDB(t)
installer := NewInstaller(database.DB)
installer.CreateAdminUser = true
installer.AdminUsername = "testadmin"
installer.AdminPassword = "testpassword123"
err := installer.createAdmin()
assert.NoError(t, err)
// Verify admin user was created
var count int64
installer.DB.Table("users").Where("username = ?", "testadmin").Count(&count)
assert.Equal(t, int64(1), count)
}
func TestCreateAdmin_AlreadyExists(t *testing.T) {
setupTestDB(t)
installer := NewInstaller(database.DB)
installer.CreateAdminUser = true
installer.AdminUsername = "testadmin"
installer.AdminPassword = "testpassword123"
// Create once
err := installer.createAdmin()
assert.NoError(t, err)
// Try to create again - should not error, just skip
err = installer.createAdmin()
assert.NoError(t, err)
// Verify only one user exists
var count int64
installer.DB.Table("users").Where("username = ?", "testadmin").Count(&count)
assert.Equal(t, int64(1), count)
}
func TestCreateAdmin_NoPassword(t *testing.T) {
setupTestDB(t)
installer := NewInstaller(database.DB)
installer.AdminUsername = "testadmin"
installer.AdminPassword = "" // Empty password
err := installer.createAdmin()
assert.Error(t, err)
assert.Contains(t, err.Error(), "password")
}
func TestCreateDatabaseSchema(t *testing.T) {
setupTestDB(t)
installer := NewInstaller(database.DB)
err := installer.createDatabaseSchema()
assert.NoError(t, err)
// Verify some key tables exist
tables := []string{"users", "chars", "gsm_skills", "gsm_spells"}
for _, table := range tables {
var exists bool
err := installer.DB.Raw("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&exists).Error
assert.NoError(t, err, "Failed to check table %s", table)
}
}
+54
View File
@@ -0,0 +1,54 @@
package masterdata
import (
"encoding/json"
"fmt"
"os"
"time"
)
// CurrentExportVersion is the current version of the export format
const CurrentExportVersion = "1.0"
// ExportData represents a versioned master data export
type ExportData struct {
ExportVersion string `json:"export_version"`
BackendVersion string `json:"backend_version"`
Timestamp time.Time `json:"timestamp"`
GameSystem string `json:"game_system"`
Data map[string]interface{} `json:"data"`
}
// ReadExportFile reads and parses an export file
func ReadExportFile(filePath string) (*ExportData, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var export ExportData
if err := json.Unmarshal(data, &export); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
// If no version specified, assume 1.0 (old format)
if export.ExportVersion == "" {
export.ExportVersion = "1.0"
}
return &export, nil
}
// WriteExportFile writes export data to a JSON file
func WriteExportFile(filePath string, export *ExportData) error {
data, err := json.MarshalIndent(export, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
@@ -0,0 +1,139 @@
package masterdata
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadExportFile(t *testing.T) {
// Create temp file with test data
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test_export.json")
exportData := &ExportData{
ExportVersion: "1.0",
BackendVersion: "0.4.0",
Timestamp: time.Now(),
GameSystem: "midgard",
Data: map[string]interface{}{
"skills": []interface{}{
map[string]interface{}{
"name": "Schwimmen",
"category": "Körper",
"difficulty": "leicht",
},
},
},
}
err := WriteExportFile(testFile, exportData)
require.NoError(t, err)
// Read it back
readData, err := ReadExportFile(testFile)
assert.NoError(t, err)
assert.NotNil(t, readData)
assert.Equal(t, "1.0", readData.ExportVersion)
assert.Equal(t, "0.4.0", readData.BackendVersion)
assert.Equal(t, "midgard", readData.GameSystem)
assert.NotNil(t, readData.Data)
}
func TestReadExportFile_NoVersion(t *testing.T) {
// Create file without version (old format)
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "old_export.json")
// Old format without export_version field
oldFormat := `{
"skills": [
{"name": "Schwimmen", "category": "Körper"}
]
}`
err := os.WriteFile(testFile, []byte(oldFormat), 0644)
require.NoError(t, err)
// Should default to version 1.0
data, err := ReadExportFile(testFile)
assert.NoError(t, err)
assert.Equal(t, "1.0", data.ExportVersion)
}
func TestWriteExportFile(t *testing.T) {
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "write_test.json")
exportData := &ExportData{
ExportVersion: "1.0",
BackendVersion: "0.4.0",
Timestamp: time.Now(),
GameSystem: "midgard",
Data: map[string]interface{}{},
}
err := WriteExportFile(testFile, exportData)
assert.NoError(t, err)
// Verify file exists and is valid JSON
fileData, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Contains(t, string(fileData), `"export_version": "1.0"`)
assert.Contains(t, string(fileData), `"game_system": "midgard"`)
}
func TestTransformToCurrentVersion_AlreadyCurrent(t *testing.T) {
data := &ExportData{
ExportVersion: CurrentExportVersion,
Data: map[string]interface{}{},
}
transformed, err := TransformToCurrentVersion(data)
assert.NoError(t, err)
assert.Equal(t, CurrentExportVersion, transformed.ExportVersion)
}
func TestRegisterTransformer(t *testing.T) {
// Save original registry
original := transformerRegistry
t.Cleanup(func() {
transformerRegistry = original
})
// Clear registry for test
transformerRegistry = []ImportTransformer{}
// Create mock transformer
mockTransformer := &mockTransformer{
canTransform: true,
targetVersion: "2.0",
}
RegisterTransformer(mockTransformer)
assert.Len(t, transformerRegistry, 1)
}
// Mock transformer for testing
type mockTransformer struct {
canTransform bool
targetVersion string
}
func (m *mockTransformer) CanTransform(version string) bool {
return m.canTransform
}
func (m *mockTransformer) Transform(data *ExportData) (*ExportData, error) {
data.ExportVersion = m.targetVersion
return data, nil
}
func (m *mockTransformer) TargetVersion() string {
return m.targetVersion
}
+116
View File
@@ -0,0 +1,116 @@
package masterdata
import (
"bamort/gsmaster"
"bamort/logger"
"fmt"
"gorm.io/gorm"
)
// MasterDataSync orchestrates master data synchronization
type MasterDataSync struct {
ImportDir string
DB *gorm.DB
DryRun bool
Verbose bool
}
// NewMasterDataSync creates a new master data sync instance
func NewMasterDataSync(db *gorm.DB, importDir string) *MasterDataSync {
return &MasterDataSync{
ImportDir: importDir,
DB: db,
DryRun: false,
Verbose: false,
}
}
// SyncAll synchronizes all master data in dependency order
func (s *MasterDataSync) SyncAll() error {
logger.Info("Starting master data synchronization from %s", s.ImportDir)
if s.DryRun {
logger.Info("[DRY RUN] No changes will be made")
}
// Import in dependency order (no dependencies → dependencies)
steps := []struct {
Name string
ImportFn func() error
}{
{"Sources", s.importSources},
{"Character Classes", s.importCharacterClasses},
{"Skill Categories", s.importSkillCategories},
{"Skill Difficulties", s.importSkillDifficulties},
{"Spell Schools", s.importSpellSchools},
{"Skills", s.importSkills},
{"Weapon Skills", s.importWeaponSkills},
{"Spells", s.importSpells},
{"Equipment", s.importEquipment},
{"Learning Costs", s.importLearningCosts},
}
for _, step := range steps {
if s.Verbose {
logger.Info("Importing %s...", step.Name)
}
if s.DryRun {
logger.Info("[DRY RUN] Would import %s", step.Name)
continue
}
if err := step.ImportFn(); err != nil {
return fmt.Errorf("failed to import %s: %w", step.Name, err)
}
if s.Verbose {
logger.Info("✓ %s imported successfully", step.Name)
}
}
logger.Info("Master data synchronization completed successfully")
return nil
}
// Import functions delegate to existing gsmaster package
func (s *MasterDataSync) importSources() error {
return gsmaster.ImportSources(s.ImportDir)
}
func (s *MasterDataSync) importCharacterClasses() error {
return gsmaster.ImportCharacterClasses(s.ImportDir)
}
func (s *MasterDataSync) importSkillCategories() error {
return gsmaster.ImportSkillCategories(s.ImportDir)
}
func (s *MasterDataSync) importSkillDifficulties() error {
return gsmaster.ImportSkillDifficulties(s.ImportDir)
}
func (s *MasterDataSync) importSpellSchools() error {
return gsmaster.ImportSpellSchools(s.ImportDir)
}
func (s *MasterDataSync) importSkills() error {
return gsmaster.ImportSkills(s.ImportDir)
}
func (s *MasterDataSync) importWeaponSkills() error {
return gsmaster.ImportWeaponSkills(s.ImportDir)
}
func (s *MasterDataSync) importSpells() error {
return gsmaster.ImportSpells(s.ImportDir)
}
func (s *MasterDataSync) importEquipment() error {
return gsmaster.ImportEquipment(s.ImportDir)
}
func (s *MasterDataSync) importLearningCosts() error {
return gsmaster.ImportSkillImprovementCosts(s.ImportDir)
}
@@ -0,0 +1,50 @@
package masterdata
import (
"bamort/database"
"testing"
"github.com/stretchr/testify/assert"
)
func setupTestDB(t *testing.T) {
database.SetupTestDB()
t.Cleanup(func() {
database.ResetTestDB()
})
}
func TestNewMasterDataSync(t *testing.T) {
setupTestDB(t)
sync := NewMasterDataSync(database.DB, "./testdata")
assert.NotNil(t, sync)
assert.NotNil(t, sync.DB)
assert.Equal(t, "./testdata", sync.ImportDir)
assert.False(t, sync.DryRun)
assert.False(t, sync.Verbose)
}
func TestSyncAll_DryRun(t *testing.T) {
setupTestDB(t)
sync := NewMasterDataSync(database.DB, "./testdata")
sync.DryRun = true
sync.Verbose = true
// In dry-run mode, should not error even if directory doesn't exist
err := sync.SyncAll()
assert.NoError(t, err)
}
func TestSyncAll_InvalidDirectory(t *testing.T) {
setupTestDB(t)
sync := NewMasterDataSync(database.DB, "/nonexistent/path")
sync.Verbose = true
// Should error when trying to import from non-existent directory
err := sync.SyncAll()
assert.Error(t, err)
}
@@ -0,0 +1,66 @@
package masterdata
import (
"bamort/logger"
"fmt"
)
// ImportTransformer transforms export data from one version to another
type ImportTransformer interface {
CanTransform(exportVersion string) bool
Transform(data *ExportData) (*ExportData, error)
TargetVersion() string
}
// transformerRegistry holds all registered transformers
var transformerRegistry = []ImportTransformer{
// Add transformers here as needed
// Example: &V1ToV2Transformer{},
}
// TransformToCurrentVersion transforms export data to the current version
func TransformToCurrentVersion(data *ExportData) (*ExportData, error) {
if data.ExportVersion == CurrentExportVersion {
logger.Debug("Export already at current version %s", CurrentExportVersion)
return data, nil
}
logger.Info("Transforming export from version %s to %s", data.ExportVersion, CurrentExportVersion)
// Apply transformers in sequence
currentVersion := data.ExportVersion
transformedData := data
for currentVersion != CurrentExportVersion {
transformed := false
for _, transformer := range transformerRegistry {
if transformer.CanTransform(currentVersion) {
logger.Debug("Applying transformer: %s → %s", currentVersion, transformer.TargetVersion())
var err error
transformedData, err = transformer.Transform(transformedData)
if err != nil {
return nil, fmt.Errorf("transformation failed (%s → %s): %w",
currentVersion, transformer.TargetVersion(), err)
}
currentVersion = transformedData.ExportVersion
transformed = true
break
}
}
if !transformed {
return nil, fmt.Errorf("no transformer found for version %s", currentVersion)
}
}
logger.Info("Transformation complete: %s → %s", data.ExportVersion, CurrentExportVersion)
return transformedData, nil
}
// RegisterTransformer adds a transformer to the registry
func RegisterTransformer(transformer ImportTransformer) {
transformerRegistry = append(transformerRegistry, transformer)
}