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:
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user