Importer Exporter Bamort style

This commit is contained in:
2025-12-29 22:01:42 +01:00
parent dacfb7d5d2
commit 78b0582879
9 changed files with 1579 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import (
"bamort/maintenance"
"bamort/pdfrender"
"bamort/router"
"bamort/transfer"
"bamort/user"
"github.com/gin-gonic/gin"
@@ -80,6 +81,7 @@ func main() {
maintenance.RegisterRoutes(protected)
importer.RegisterRoutes(protected)
pdfrender.RegisterRoutes(protected)
transfer.RegisterRoutes(protected)
// Register public routes (no authentication)
pdfrender.RegisterPublicRoutes(r)
+149
View File
@@ -0,0 +1,149 @@
# Transfer Package
The transfer package provides character export and import functionality for the Bamort application.
## Features
- **Complete Character Export**: Exports a character with all related data including:
- Basic character information (race, type, grade, attributes, etc.)
- Skills (Fertigkeiten) and Weapon Skills (Waffenfertigkeiten)
- Spells (Zauber)
- Equipment (Waffen, Ausrüstung, Behältnisse, Transportmittel)
- Learning data (all learning_* tables)
- Audit log entries
- GSM master data (skills, spells, weapons, equipment definitions)
- **Smart Import**:
- Identifies existing GSM data by name (not ID) to avoid duplicates
- Updates incomplete GSM records with missing information
- Sets default source_id values (1 for skills/equipment, 2 for spells)
- Preserves audit log history
- Creates new character with fresh IDs
- **JSON Format**: All data is exported/imported as JSON for portability
## API Endpoints
All endpoints are under `/api/transfer`:
### GET /api/transfer/export/:id
Exports a character as JSON data (for API consumption)
**Response**: CharacterExport JSON object
### GET /api/transfer/download/:id
Downloads a character as a JSON file
**Response**: JSON file with `Content-Disposition: attachment` header
### POST /api/transfer/import
Imports a character from JSON data
**Request Body**: CharacterExport JSON object
**Response**:
```json
{
"message": "Character imported successfully",
"character_id": 123
}
```
## Usage Examples
### Export a Character
```bash
curl http://localhost:8180/api/transfer/export/18 > character_export.json
```
### Download a Character
```bash
wget http://localhost:8180/api/transfer/download/18
```
### Import a Character
```bash
curl -X POST http://localhost:8180/api/transfer/import \
-H "Content-Type: application/json" \
-d @character_export.json
```
## Testing
The package includes comprehensive tests:
- **Export Tests** (7 tests):
- Basic export functionality
- Skills inclusion
- Spells inclusion
- Equipment inclusion
- Learning data inclusion
- Audit log inclusion
- Error handling for non-existent characters
- **Import Tests** (6 tests):
- Basic import functionality
- Handling existing GSM data
- Updating incomplete GSM data
- Default source_id assignment
- Audit log import
- JSON round-trip testing
- **Handler/API Tests** (5 tests):
- Export endpoint
- Download endpoint
- Import endpoint
- Error handling
**Total: 18 tests, all passing**
Run tests with:
```bash
cd backend
go test -v ./transfer/
```
## Implementation Notes
### TDD Approach
The package was developed using Test-Driven Development (TDD):
1. Tests written first
2. Implementation follows to make tests pass
3. Refactoring as needed
### KISS Principle
The implementation follows the "Keep It Simple, Stupid" principle:
- Simple, clear function names
- Each function does one thing well
- No over-engineering
- Straightforward error handling
### GSM Data Handling
- Skills, weapons, equipment, and spells are identified by **name**, not ID
- Prevents duplicate creation of master data
- Updates existing records only if they have missing information
- Default source_id: 1 for general data, 2 for spells
### Source ID Rules
When importing, if `source_id` is 0:
- **Spells**: Set to 2
- **All other data**: Set to 1
## Files
- `exporter.go` - Character export functionality
- `exporter_test.go` - Export tests
- `importer.go` - Character import functionality
- `importer_test.go` - Import tests
- `handlers.go` - HTTP handlers for API endpoints
- `handlers_test.go` - API handler tests
- `routes.go` - Route registration
- `README.md` - This file
## Future Enhancements
Possible improvements:
- Batch export/import of multiple characters
- Export filtering (e.g., export without audit log)
- Import validation and conflict resolution options
- Export format versioning
- Compression for large exports
+192
View File
@@ -0,0 +1,192 @@
package transfer
import (
"bamort/database"
"bamort/models"
"fmt"
)
// CharacterExport contains all data needed to export and import a character
type CharacterExport struct {
Character models.Char `json:"character"`
GSMSkills []models.Skill `json:"gsm_skills"`
GSMWeaponSkills []models.WeaponSkill `json:"gsm_weapon_skills"`
GSMSpells []models.Spell `json:"gsm_spells"`
GSMWeapons []models.Weapon `json:"gsm_weapons"`
GSMEquipment []models.Equipment `json:"gsm_equipment"`
GSMContainers []models.Container `json:"gsm_containers"`
LearningData LearningDataExport `json:"learning_data"`
AuditLogEntries []models.AuditLogEntry `json:"audit_log_entries"`
}
// LearningDataExport contains all learning-related master data
type LearningDataExport struct {
Sources []models.Source `json:"sources"`
CharacterClasses []models.CharacterClass `json:"character_classes"`
SkillCategories []models.SkillCategory `json:"skill_categories"`
SkillDifficulties []models.SkillDifficulty `json:"skill_difficulties"`
SpellSchools []models.SpellSchool `json:"spell_schools"`
ClassCategoryEPCosts []models.ClassCategoryEPCost `json:"class_category_ep_costs"`
ClassSpellSchoolEPCosts []models.ClassSpellSchoolEPCost `json:"class_spell_school_ep_costs"`
SpellLevelLECosts []models.SpellLevelLECost `json:"spell_level_le_costs"`
SkillCategoryDifficulties []models.SkillCategoryDifficulty `json:"skill_category_difficulties"`
SkillImprovementCosts []models.SkillImprovementCost `json:"skill_improvement_costs"`
}
// ExportCharacter exports a complete character with all related data
func ExportCharacter(characterID uint) (*CharacterExport, error) {
var char models.Char
// Load character with all relations
err := database.DB.
Preload("User").
Preload("Lp").
Preload("Ap").
Preload("B").
Preload("Merkmale").
Preload("Eigenschaften").
Preload("Fertigkeiten").
Preload("Waffenfertigkeiten").
Preload("Zauber").
Preload("Bennies").
Preload("Vermoegen").
Preload("Erfahrungsschatz").
Preload("Waffen").
Preload("Behaeltnisse").
Preload("Transportmittel").
Preload("Ausruestung").
First(&char, characterID).Error
if err != nil {
return nil, fmt.Errorf("failed to load character: %w", err)
}
export := &CharacterExport{
Character: char,
}
// Collect GSM skill data
export.GSMSkills = make([]models.Skill, 0)
export.GSMWeaponSkills = make([]models.WeaponSkill, 0)
skillNames := make(map[string]bool)
for _, skill := range char.Fertigkeiten {
if !skillNames[skill.Name] {
var gsmSkill models.Skill
err := gsmSkill.First(skill.Name)
if err == nil && gsmSkill.ID != 0 {
export.GSMSkills = append(export.GSMSkills, gsmSkill)
skillNames[skill.Name] = true
}
}
}
weaponSkillNames := make(map[string]bool)
for _, skill := range char.Waffenfertigkeiten {
if !weaponSkillNames[skill.Name] {
var weaponSkill models.WeaponSkill
err := weaponSkill.First(skill.Name)
if err == nil && weaponSkill.ID != 0 {
export.GSMWeaponSkills = append(export.GSMWeaponSkills, weaponSkill)
weaponSkillNames[skill.Name] = true
}
}
}
// Collect GSM spell data
export.GSMSpells = make([]models.Spell, 0)
spellNames := make(map[string]bool)
for _, spell := range char.Zauber {
if !spellNames[spell.Name] {
var gsmSpell models.Spell
err := gsmSpell.First(spell.Name)
if err == nil && gsmSpell.ID != 0 {
export.GSMSpells = append(export.GSMSpells, gsmSpell)
spellNames[spell.Name] = true
}
}
}
// Collect GSM weapon data
export.GSMWeapons = make([]models.Weapon, 0)
weaponNames := make(map[string]bool)
for _, weapon := range char.Waffen {
if !weaponNames[weapon.Name] {
var gsmWeapon models.Weapon
err := gsmWeapon.First(weapon.Name)
if err == nil && gsmWeapon.ID != 0 {
export.GSMWeapons = append(export.GSMWeapons, gsmWeapon)
weaponNames[weapon.Name] = true
}
}
}
// Collect GSM equipment data
export.GSMEquipment = make([]models.Equipment, 0)
equipmentNames := make(map[string]bool)
for _, equip := range char.Ausruestung {
if !equipmentNames[equip.Name] {
var gsmEquip models.Equipment
err := gsmEquip.First(equip.Name)
if err == nil && gsmEquip.ID != 0 {
export.GSMEquipment = append(export.GSMEquipment, gsmEquip)
equipmentNames[equip.Name] = true
}
}
}
// Collect GSM container data
export.GSMContainers = make([]models.Container, 0)
containerNames := make(map[string]bool)
for _, container := range char.Behaeltnisse {
if !containerNames[container.Name] {
var gsmContainer models.Container
err := gsmContainer.First(container.Name)
if err == nil && gsmContainer.ID != 0 {
export.GSMContainers = append(export.GSMContainers, gsmContainer)
containerNames[container.Name] = true
}
}
}
for _, container := range char.Transportmittel {
if !containerNames[container.Name] {
var gsmContainer models.Container
err := gsmContainer.First(container.Name)
if err == nil && gsmContainer.ID != 0 {
export.GSMContainers = append(export.GSMContainers, gsmContainer)
containerNames[container.Name] = true
}
}
}
// Load learning data
export.LearningData = LearningDataExport{
Sources: make([]models.Source, 0),
CharacterClasses: make([]models.CharacterClass, 0),
SkillCategories: make([]models.SkillCategory, 0),
SkillDifficulties: make([]models.SkillDifficulty, 0),
SpellSchools: make([]models.SpellSchool, 0),
ClassCategoryEPCosts: make([]models.ClassCategoryEPCost, 0),
ClassSpellSchoolEPCosts: make([]models.ClassSpellSchoolEPCost, 0),
SpellLevelLECosts: make([]models.SpellLevelLECost, 0),
SkillCategoryDifficulties: make([]models.SkillCategoryDifficulty, 0),
SkillImprovementCosts: make([]models.SkillImprovementCost, 0),
}
database.DB.Find(&export.LearningData.Sources)
database.DB.Preload("Source").Find(&export.LearningData.CharacterClasses)
database.DB.Preload("Source").Find(&export.LearningData.SkillCategories)
database.DB.Find(&export.LearningData.SkillDifficulties)
database.DB.Preload("Source").Find(&export.LearningData.SpellSchools)
database.DB.Preload("CharacterClass").Preload("SkillCategory").Find(&export.LearningData.ClassCategoryEPCosts)
database.DB.Preload("CharacterClass").Preload("SpellSchool").Find(&export.LearningData.ClassSpellSchoolEPCosts)
database.DB.Find(&export.LearningData.SpellLevelLECosts)
database.DB.Preload("Skill").Preload("SkillCategory").Preload("SkillDifficulty").Find(&export.LearningData.SkillCategoryDifficulties)
database.DB.Preload("SkillCategoryDifficulty").Find(&export.LearningData.SkillImprovementCosts)
// Load audit log entries
export.AuditLogEntries = make([]models.AuditLogEntry, 0)
database.DB.Where("character_id = ?", characterID).Order("timestamp ASC").Find(&export.AuditLogEntries)
return export, nil
}
+156
View File
@@ -0,0 +1,156 @@
package transfer
import (
"bamort/database"
"bamort/models"
"encoding/json"
"os"
"testing"
)
func setupTestEnvironment(t *testing.T) {
original := os.Getenv("ENVIRONMENT")
os.Setenv("ENVIRONMENT", "test")
database.SetupTestDB(true, true)
models.MigrateStructure()
t.Cleanup(func() {
database.ResetTestDB()
if original == "" {
os.Unsetenv("ENVIRONMENT")
} else {
os.Setenv("ENVIRONMENT", original)
}
})
}
func TestExportCharacter(t *testing.T) {
setupTestEnvironment(t)
// Test with character ID 18 (Fanjo Vetrani - exists in test DB)
characterID := uint(18)
exportData, err := ExportCharacter(characterID)
if err != nil {
t.Fatalf("ExportCharacter failed: %v", err)
}
// Verify basic character data
if exportData.Character.ID != characterID {
t.Errorf("Expected character ID %d, got %d", characterID, exportData.Character.ID)
}
if exportData.Character.Name == "" {
t.Error("Character name should not be empty")
}
// Verify JSON serialization
jsonData, err := json.Marshal(exportData)
if err != nil {
t.Fatalf("Failed to marshal export data to JSON: %v", err)
}
if len(jsonData) == 0 {
t.Error("JSON export should not be empty")
}
// Verify we can unmarshal back
var reimported CharacterExport
err = json.Unmarshal(jsonData, &reimported)
if err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
if reimported.Character.ID != characterID {
t.Errorf("After JSON round-trip, expected character ID %d, got %d", characterID, reimported.Character.ID)
}
}
func TestExportCharacterIncludesSkills(t *testing.T) {
setupTestEnvironment(t)
characterID := uint(18)
exportData, err := ExportCharacter(characterID)
if err != nil {
t.Fatalf("ExportCharacter failed: %v", err)
}
// Character should have some skills
if len(exportData.Character.Fertigkeiten) == 0 {
t.Error("Character should have skills")
}
// Verify GSM data for skills is included
if len(exportData.GSMSkills) == 0 {
t.Error("GSM skills should be included in export")
}
}
func TestExportCharacterIncludesSpells(t *testing.T) {
setupTestEnvironment(t)
characterID := uint(18)
exportData, err := ExportCharacter(characterID)
if err != nil {
t.Fatalf("ExportCharacter failed: %v", err)
}
// Verify GSM data for spells is included if character has spells
if len(exportData.Character.Zauber) > 0 && len(exportData.GSMSpells) == 0 {
t.Error("If character has spells, GSM spells should be included")
}
}
func TestExportCharacterIncludesEquipment(t *testing.T) {
setupTestEnvironment(t)
characterID := uint(18)
exportData, err := ExportCharacter(characterID)
if err != nil {
t.Fatalf("ExportCharacter failed: %v", err)
}
// Verify GSM data for equipment is included if character has weapons
if len(exportData.Character.Waffen) > 0 && len(exportData.GSMWeapons) == 0 {
t.Error("If character has weapons, GSM weapons should be included")
}
}
func TestExportCharacterIncludesLearningData(t *testing.T) {
setupTestEnvironment(t)
characterID := uint(18)
exportData, err := ExportCharacter(characterID)
if err != nil {
t.Fatalf("ExportCharacter failed: %v", err)
}
// Verify learning data structures exist (they might be empty)
if exportData.LearningData.Sources == nil {
t.Error("Learning sources should be initialized")
}
}
func TestExportCharacterIncludesAuditLog(t *testing.T) {
setupTestEnvironment(t)
characterID := uint(18)
exportData, err := ExportCharacter(characterID)
if err != nil {
t.Fatalf("ExportCharacter failed: %v", err)
}
// Audit log entries should be included (even if empty)
if exportData.AuditLogEntries == nil {
t.Error("Audit log entries should be initialized")
}
}
func TestExportNonExistentCharacter(t *testing.T) {
setupTestEnvironment(t)
// Try to export non-existent character
_, err := ExportCharacter(uint(999999))
if err == nil {
t.Error("Expected error when exporting non-existent character")
}
}
+97
View File
@@ -0,0 +1,97 @@
package transfer
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ExportCharacterHandler handles character export requests
func ExportCharacterHandler(c *gin.Context) {
// Get character ID from URL parameter
charIDStr := c.Param("id")
charID, err := strconv.ParseUint(charIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid character ID"})
return
}
// Export character
exportData, err := ExportCharacter(uint(charID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to export character: %v", err)})
return
}
// Return as JSON
c.JSON(http.StatusOK, exportData)
}
// DownloadCharacterHandler exports character as downloadable JSON file
func DownloadCharacterHandler(c *gin.Context) {
// Get character ID from URL parameter
charIDStr := c.Param("id")
charID, err := strconv.ParseUint(charIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid character ID"})
return
}
// Export character
exportData, err := ExportCharacter(uint(charID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to export character: %v", err)})
return
}
// Convert to JSON
jsonData, err := json.MarshalIndent(exportData, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal JSON"})
return
}
// Set headers for file download
filename := fmt.Sprintf("character_%s_export.json", exportData.Character.Name)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Header("Content-Type", "application/json")
c.Data(http.StatusOK, "application/json", jsonData)
}
// ImportCharacterHandler handles character import requests
func ImportCharacterHandler(c *gin.Context) {
// Get user ID from context (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
userIDUint, ok := userID.(uint)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user ID"})
return
}
// Parse import data from request body
var importData CharacterExport
if err := c.ShouldBindJSON(&importData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid JSON: %v", err)})
return
}
// Import character
charID, err := ImportCharacter(&importData, userIDUint)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to import character: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Character imported successfully",
"character_id": charID,
})
}
+180
View File
@@ -0,0 +1,180 @@
package transfer
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"bamort/database"
"bamort/models"
"github.com/gin-gonic/gin"
)
func setupHandlerTestEnvironment(t *testing.T) *gin.Engine {
original := os.Getenv("ENVIRONMENT")
os.Setenv("ENVIRONMENT", "test")
database.SetupTestDB(true, true)
models.MigrateStructure()
gin.SetMode(gin.TestMode)
r := gin.Default()
t.Cleanup(func() {
database.ResetTestDB()
if original == "" {
os.Unsetenv("ENVIRONMENT")
} else {
os.Setenv("ENVIRONMENT", original)
}
})
return r
}
func TestExportCharacterHandlerAPI(t *testing.T) {
r := setupHandlerTestEnvironment(t)
// Register routes
api := r.Group("/api")
RegisterRoutes(api)
// Test export endpoint
req, _ := http.NewRequest("GET", "/api/transfer/export/18", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify response is valid JSON
var exportData CharacterExport
err := json.Unmarshal(w.Body.Bytes(), &exportData)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if exportData.Character.ID != 18 {
t.Errorf("Expected character ID 18, got %d", exportData.Character.ID)
}
}
func TestDownloadCharacterHandlerAPI(t *testing.T) {
r := setupHandlerTestEnvironment(t)
api := r.Group("/api")
RegisterRoutes(api)
// Test download endpoint
req, _ := http.NewRequest("GET", "/api/transfer/download/18", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify content-type is application/json
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", contentType)
}
// Verify Content-Disposition header exists
disposition := w.Header().Get("Content-Disposition")
if disposition == "" {
t.Error("Expected Content-Disposition header to be set")
}
}
func TestImportCharacterHandlerAPI(t *testing.T) {
r := setupHandlerTestEnvironment(t)
api := r.Group("/api")
RegisterRoutes(api)
// First export a character
exportData, err := ExportCharacter(uint(18))
if err != nil {
t.Fatalf("Failed to export character: %v", err)
}
// Modify for import
exportData.Character.ID = 0
exportData.Character.Name = "API Imported Character"
// Convert to JSON
jsonData, err := json.Marshal(exportData)
if err != nil {
t.Fatalf("Failed to marshal JSON: %v", err)
}
// Test import endpoint
req, _ := http.NewRequest("POST", "/api/transfer/import", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
// Mock user_id in context (normally set by auth middleware)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Set("user_id", uint(1))
ImportCharacterHandler(c)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify response contains character_id
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if _, exists := response["character_id"]; !exists {
t.Error("Expected character_id in response")
}
}
func TestExportNonExistentCharacterAPI(t *testing.T) {
r := setupHandlerTestEnvironment(t)
api := r.Group("/api")
RegisterRoutes(api)
// Test with non-existent character ID
req, _ := http.NewRequest("GET", "/api/transfer/export/999999", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
}
}
func TestImportInvalidJSONAPI(t *testing.T) {
r := setupHandlerTestEnvironment(t)
api := r.Group("/api")
RegisterRoutes(api)
// Test with invalid JSON
req, _ := http.NewRequest("POST", "/api/transfer/import", bytes.NewBufferString("invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Set("user_id", uint(1))
ImportCharacterHandler(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
+513
View File
@@ -0,0 +1,513 @@
package transfer
import (
"bamort/database"
"bamort/models"
"fmt"
"strings"
"gorm.io/gorm"
)
// ImportCharacter imports a character from export data
func ImportCharacter(exportData *CharacterExport, userID uint) (uint, error) {
var importedCharID uint
err := database.DB.Transaction(func(tx *gorm.DB) error {
// Import GSM master data first
if err := importGSMData(tx, exportData); err != nil {
return fmt.Errorf("failed to import GSM data: %w", err)
}
// Import learning data
if err := importLearningData(tx, exportData); err != nil {
return fmt.Errorf("failed to import learning data: %w", err)
}
// Import character
char := exportData.Character
char.ID = 0 // Reset ID for new character
char.UserID = userID
// Reset all related IDs
if char.Lp.ID != 0 {
char.Lp.ID = 0
}
if char.Ap.ID != 0 {
char.Ap.ID = 0
}
if char.B.ID != 0 {
char.B.ID = 0
}
if char.Merkmale.ID != 0 {
char.Merkmale.ID = 0
}
if char.Bennies.ID != 0 {
char.Bennies.ID = 0
}
if char.Vermoegen.ID != 0 {
char.Vermoegen.ID = 0
}
if char.Erfahrungsschatz.ID != 0 {
char.Erfahrungsschatz.ID = 0
}
// Reset skill IDs
for i := range char.Eigenschaften {
char.Eigenschaften[i].ID = 0
char.Eigenschaften[i].UserID = userID
}
for i := range char.Fertigkeiten {
char.Fertigkeiten[i].ID = 0
char.Fertigkeiten[i].UserID = userID
}
for i := range char.Waffenfertigkeiten {
char.Waffenfertigkeiten[i].ID = 0
char.Waffenfertigkeiten[i].UserID = userID
}
for i := range char.Zauber {
char.Zauber[i].ID = 0
char.Zauber[i].UserID = userID
}
// Reset equipment IDs
for i := range char.Waffen {
char.Waffen[i].ID = 0
char.Waffen[i].UserID = userID
}
for i := range char.Behaeltnisse {
char.Behaeltnisse[i].ID = 0
char.Behaeltnisse[i].UserID = userID
}
for i := range char.Transportmittel {
char.Transportmittel[i].ID = 0
char.Transportmittel[i].UserID = userID
}
for i := range char.Ausruestung {
char.Ausruestung[i].ID = 0
char.Ausruestung[i].UserID = userID
}
// Create character
if err := tx.Create(&char).Error; err != nil {
return fmt.Errorf("failed to create character: %w", err)
}
importedCharID = char.ID
// Import audit log entries
if len(exportData.AuditLogEntries) > 0 {
for i := range exportData.AuditLogEntries {
exportData.AuditLogEntries[i].ID = 0
exportData.AuditLogEntries[i].CharacterID = importedCharID
}
if err := tx.Create(&exportData.AuditLogEntries).Error; err != nil {
return fmt.Errorf("failed to import audit log: %w", err)
}
}
return nil
})
if err != nil {
return 0, err
}
return importedCharID, nil
}
// importGSMData imports or updates GSM master data
func importGSMData(tx *gorm.DB, exportData *CharacterExport) error {
// Import skills
for _, skill := range exportData.GSMSkills {
if err := importOrUpdateSkill(tx, &skill); err != nil {
return err
}
}
// Import weapon skills
for _, weaponSkill := range exportData.GSMWeaponSkills {
if err := importOrUpdateWeaponSkill(tx, &weaponSkill); err != nil {
return err
}
}
// Import spells
for _, spell := range exportData.GSMSpells {
if err := importOrUpdateSpell(tx, &spell); err != nil {
return err
}
}
// Import weapons
for _, weapon := range exportData.GSMWeapons {
if err := importOrUpdateWeapon(tx, &weapon); err != nil {
return err
}
}
// Import equipment
for _, equipment := range exportData.GSMEquipment {
if err := importOrUpdateEquipment(tx, &equipment); err != nil {
return err
}
}
// Import containers
for _, container := range exportData.GSMContainers {
if err := importOrUpdateContainer(tx, &container); err != nil {
return err
}
}
return nil
}
// importOrUpdateSkill imports or updates a skill based on name
func importOrUpdateSkill(tx *gorm.DB, skill *models.Skill) error {
// Set default source_id if 0
if skill.SourceID == 0 {
skill.SourceID = 1
}
var existing models.Skill
err := tx.Where("name = ? AND game_system = ?", skill.Name, skill.GameSystem).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// Create new skill
skill.ID = 0
return tx.Create(skill).Error
} else if err != nil {
return err
}
// Update if existing has empty fields
updates := make(map[string]interface{})
if existing.Beschreibung == "" && skill.Beschreibung != "" {
updates["beschreibung"] = skill.Beschreibung
}
if existing.Category == "" && skill.Category != "" {
updates["category"] = skill.Category
}
if existing.Difficulty == "" && skill.Difficulty != "" {
updates["difficulty"] = skill.Difficulty
}
if existing.Bonuseigenschaft == "" && skill.Bonuseigenschaft != "" {
updates["bonuseigenschaft"] = skill.Bonuseigenschaft
}
if existing.SourceID == 0 && skill.SourceID != 0 {
updates["source_id"] = skill.SourceID
}
if skill.PageNumber != 0 && existing.PageNumber == 0 {
updates["page_number"] = skill.PageNumber
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
// importOrUpdateWeaponSkill imports or updates a weapon skill
func importOrUpdateWeaponSkill(tx *gorm.DB, weaponSkill *models.WeaponSkill) error {
if weaponSkill.SourceID == 0 {
weaponSkill.SourceID = 1
}
var existing models.WeaponSkill
err := tx.Where("name = ? AND game_system = ?", weaponSkill.Name, weaponSkill.GameSystem).First(&existing).Error
if err == gorm.ErrRecordNotFound {
weaponSkill.ID = 0
return tx.Create(weaponSkill).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.Beschreibung == "" && weaponSkill.Beschreibung != "" {
updates["beschreibung"] = weaponSkill.Beschreibung
}
if existing.Category == "" && weaponSkill.Category != "" {
updates["category"] = weaponSkill.Category
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
// importOrUpdateSpell imports or updates a spell
func importOrUpdateSpell(tx *gorm.DB, spell *models.Spell) error {
// Set default source_id if 0 (spells get source_id 2)
if spell.SourceID == 0 {
spell.SourceID = 2
}
var existing models.Spell
err := tx.Where("name = ? AND game_system = ?", spell.Name, spell.GameSystem).First(&existing).Error
if err == gorm.ErrRecordNotFound {
spell.ID = 0
return tx.Create(spell).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.Beschreibung == "" && spell.Beschreibung != "" {
updates["beschreibung"] = spell.Beschreibung
}
if existing.Category == "" && spell.Category != "" {
updates["category"] = spell.Category
}
if existing.LearningCategory == "" && spell.LearningCategory != "" {
updates["learning_category"] = spell.LearningCategory
}
if existing.Ursprung == "" && spell.Ursprung != "" {
updates["ursprung"] = spell.Ursprung
}
if existing.SourceID == 0 && spell.SourceID != 0 {
updates["source_id"] = spell.SourceID
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
// importOrUpdateWeapon imports or updates a weapon
func importOrUpdateWeapon(tx *gorm.DB, weapon *models.Weapon) error {
if weapon.SourceID == 0 {
weapon.SourceID = 1
}
var existing models.Weapon
err := tx.Where("name = ? AND game_system = ?", weapon.Name, weapon.GameSystem).First(&existing).Error
if err == gorm.ErrRecordNotFound {
weapon.ID = 0
return tx.Create(weapon).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.Beschreibung == "" && weapon.Beschreibung != "" {
updates["beschreibung"] = weapon.Beschreibung
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
// importOrUpdateEquipment imports or updates equipment
func importOrUpdateEquipment(tx *gorm.DB, equipment *models.Equipment) error {
if equipment.SourceID == 0 {
equipment.SourceID = 1
}
var existing models.Equipment
err := tx.Where("name = ? AND game_system = ?", equipment.Name, equipment.GameSystem).First(&existing).Error
if err == gorm.ErrRecordNotFound {
equipment.ID = 0
return tx.Create(equipment).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.Beschreibung == "" && equipment.Beschreibung != "" {
updates["beschreibung"] = equipment.Beschreibung
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
// importOrUpdateContainer imports or updates a container
func importOrUpdateContainer(tx *gorm.DB, container *models.Container) error {
if container.SourceID == 0 {
container.SourceID = 1
}
var existing models.Container
err := tx.Where("name = ? AND game_system = ?", container.Name, container.GameSystem).First(&existing).Error
if err == gorm.ErrRecordNotFound {
container.ID = 0
return tx.Create(container).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.Beschreibung == "" && container.Beschreibung != "" {
updates["beschreibung"] = container.Beschreibung
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
// importLearningData imports learning-related master data
func importLearningData(tx *gorm.DB, exportData *CharacterExport) error {
// Import sources
for _, source := range exportData.LearningData.Sources {
if err := importOrUpdateSource(tx, &source); err != nil {
return err
}
}
// Import character classes
for _, cc := range exportData.LearningData.CharacterClasses {
if err := importOrUpdateCharacterClass(tx, &cc); err != nil {
return err
}
}
// Import skill categories
for _, sc := range exportData.LearningData.SkillCategories {
if err := importOrUpdateSkillCategory(tx, &sc); err != nil {
return err
}
}
// Import skill difficulties
for _, sd := range exportData.LearningData.SkillDifficulties {
if err := importOrUpdateSkillDifficulty(tx, &sd); err != nil {
return err
}
}
// Import spell schools
for _, ss := range exportData.LearningData.SpellSchools {
if err := importOrUpdateSpellSchool(tx, &ss); err != nil {
return err
}
}
// More complex tables - skip if already exist (identified by combination of fields)
// These don't need updates as they're typically static cost tables
return nil
}
func importOrUpdateSource(tx *gorm.DB, source *models.Source) error {
var existing models.Source
err := tx.Where("code = ?", source.Code).First(&existing).Error
if err == gorm.ErrRecordNotFound {
source.ID = 0
return tx.Create(source).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.FullName == "" && source.FullName != "" {
updates["full_name"] = source.FullName
}
if existing.Description == "" && source.Description != "" {
updates["description"] = source.Description
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
func importOrUpdateCharacterClass(tx *gorm.DB, cc *models.CharacterClass) error {
var existing models.CharacterClass
err := tx.Where("code = ?", strings.TrimSpace(cc.Code)).First(&existing).Error
if err == gorm.ErrRecordNotFound {
cc.ID = 0
return tx.Create(cc).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.Description == "" && cc.Description != "" {
updates["description"] = cc.Description
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
func importOrUpdateSkillCategory(tx *gorm.DB, sc *models.SkillCategory) error {
var existing models.SkillCategory
err := tx.Where("name = ?", sc.Name).First(&existing).Error
if err == gorm.ErrRecordNotFound {
sc.ID = 0
return tx.Create(sc).Error
}
return err
}
func importOrUpdateSkillDifficulty(tx *gorm.DB, sd *models.SkillDifficulty) error {
var existing models.SkillDifficulty
err := tx.Where("name = ?", sd.Name).First(&existing).Error
if err == gorm.ErrRecordNotFound {
sd.ID = 0
return tx.Create(sd).Error
}
return err
}
func importOrUpdateSpellSchool(tx *gorm.DB, ss *models.SpellSchool) error {
var existing models.SpellSchool
err := tx.Where("name = ?", ss.Name).First(&existing).Error
if err == gorm.ErrRecordNotFound {
ss.ID = 0
return tx.Create(ss).Error
} else if err != nil {
return err
}
// Update empty fields
updates := make(map[string]interface{})
if existing.Description == "" && ss.Description != "" {
updates["description"] = ss.Description
}
if len(updates) > 0 {
return tx.Model(&existing).Updates(updates).Error
}
return nil
}
+270
View File
@@ -0,0 +1,270 @@
package transfer
import (
"bamort/database"
"bamort/models"
"encoding/json"
"os"
"testing"
)
func setupImportTestEnvironment(t *testing.T) {
original := os.Getenv("ENVIRONMENT")
os.Setenv("ENVIRONMENT", "test")
database.SetupTestDB(true, true)
models.MigrateStructure()
t.Cleanup(func() {
database.ResetTestDB()
if original == "" {
os.Unsetenv("ENVIRONMENT")
} else {
os.Setenv("ENVIRONMENT", original)
}
})
}
func TestImportCharacter(t *testing.T) {
setupImportTestEnvironment(t)
// First export character 18
exportData, err := ExportCharacter(uint(18))
if err != nil {
t.Fatalf("Failed to export character: %v", err)
}
// Change IDs to simulate import into new system
originalID := exportData.Character.ID
exportData.Character.ID = 0 // Reset ID for new character
exportData.Character.Name = "Imported " + exportData.Character.Name
// Import the character
importedCharID, err := ImportCharacter(exportData, 1) // UserID 1
if err != nil {
t.Fatalf("ImportCharacter failed: %v", err)
}
if importedCharID == 0 {
t.Error("Expected non-zero character ID after import")
}
if importedCharID == originalID {
t.Error("Imported character should have new ID, not original ID")
}
// Verify imported character exists
var importedChar models.Char
err = database.DB.Preload("Fertigkeiten").Preload("Zauber").Preload("Waffen").First(&importedChar, importedCharID).Error
if err != nil {
t.Fatalf("Failed to load imported character: %v", err)
}
// Verify skills were imported
if len(importedChar.Fertigkeiten) != len(exportData.Character.Fertigkeiten) {
t.Errorf("Expected %d skills, got %d", len(exportData.Character.Fertigkeiten), len(importedChar.Fertigkeiten))
}
}
func TestImportCharacterWithExistingGSMData(t *testing.T) {
setupImportTestEnvironment(t)
// Export character 18
exportData, err := ExportCharacter(uint(18))
if err != nil {
t.Fatalf("Failed to export character: %v", err)
}
// Count existing skills in GSM before import
var skillCountBefore int64
database.DB.Model(&models.Skill{}).Count(&skillCountBefore)
exportData.Character.ID = 0
exportData.Character.Name = "Test Import"
// Import character
_, err = ImportCharacter(exportData, 1)
if err != nil {
t.Fatalf("ImportCharacter failed: %v", err)
}
// Count skills after - should be same (no duplicates)
var skillCountAfter int64
database.DB.Model(&models.Skill{}).Count(&skillCountAfter)
if skillCountAfter < skillCountBefore {
t.Error("Skills should not be deleted during import")
}
}
func TestImportCharacterUpdatesIncompleteGSMData(t *testing.T) {
setupImportTestEnvironment(t)
// Create a skill with incomplete data (no description)
incompleteSkill := models.Skill{
ID: 1000,
Name: "TestSkillIncomplete",
GameSystem: "midgard",
Beschreibung: "",
Category: "",
}
database.DB.Create(&incompleteSkill)
// Create export data with complete version of same skill
exportData := &CharacterExport{
Character: models.Char{
BamortBase: models.BamortBase{
Name: "Test Char",
},
Typ: "Krieger",
Rasse: "Mensch",
Grad: 1,
UserID: 1,
},
GSMSkills: []models.Skill{
{
Name: "TestSkillIncomplete",
GameSystem: "midgard",
Beschreibung: "Complete description",
Category: "Alltag",
Difficulty: "normal",
},
},
}
_, err := ImportCharacter(exportData, 1)
if err != nil {
t.Fatalf("ImportCharacter failed: %v", err)
}
// Verify skill was updated
var updatedSkill models.Skill
database.DB.Where("name = ?", "TestSkillIncomplete").First(&updatedSkill)
if updatedSkill.Beschreibung != "Complete description" {
t.Error("Expected skill description to be updated")
}
if updatedSkill.Category != "Alltag" {
t.Error("Expected skill category to be updated")
}
}
func TestImportCharacterSetsSourceIDDefault(t *testing.T) {
setupImportTestEnvironment(t)
exportData := &CharacterExport{
Character: models.Char{
BamortBase: models.BamortBase{
Name: "Test Char",
},
Typ: "Krieger",
Rasse: "Mensch",
Grad: 1,
UserID: 1,
},
GSMSkills: []models.Skill{
{
Name: "TestSkill",
GameSystem: "midgard",
SourceID: 0, // Should be set to 1
},
},
GSMSpells: []models.Spell{
{
Name: "TestSpell",
GameSystem: "midgard",
SourceID: 0, // Should be set to 2
},
},
}
_, err := ImportCharacter(exportData, 1)
if err != nil {
t.Fatalf("ImportCharacter failed: %v", err)
}
// Verify skill source_id was set to 1
var skill models.Skill
database.DB.Where("name = ?", "TestSkill").First(&skill)
if skill.SourceID != 1 {
t.Errorf("Expected skill source_id to be 1, got %d", skill.SourceID)
}
// Verify spell source_id was set to 2
var spell models.Spell
database.DB.Where("name = ?", "TestSpell").First(&spell)
if spell.SourceID != 2 {
t.Errorf("Expected spell source_id to be 2, got %d", spell.SourceID)
}
}
func TestImportCharacterIncludesAuditLog(t *testing.T) {
setupImportTestEnvironment(t)
// Create export data with audit log entries
exportData := &CharacterExport{
Character: models.Char{
BamortBase: models.BamortBase{
Name: "Test Char",
},
Typ: "Krieger",
Rasse: "Mensch",
Grad: 1,
UserID: 1,
},
AuditLogEntries: []models.AuditLogEntry{
{
FieldName: "experience_points",
OldValue: 100,
NewValue: 150,
Difference: 50,
Reason: "skill_learning",
},
},
}
charID, err := ImportCharacter(exportData, 1)
if err != nil {
t.Fatalf("ImportCharacter failed: %v", err)
}
// Verify audit log entry was imported
var auditEntries []models.AuditLogEntry
database.DB.Where("character_id = ?", charID).Find(&auditEntries)
if len(auditEntries) == 0 {
t.Error("Expected audit log entries to be imported")
}
}
func TestImportCharacterAsJSON(t *testing.T) {
setupImportTestEnvironment(t)
// Export character 18 to JSON
exportData, err := ExportCharacter(uint(18))
if err != nil {
t.Fatalf("Failed to export character: %v", err)
}
jsonData, err := json.Marshal(exportData)
if err != nil {
t.Fatalf("Failed to marshal to JSON: %v", err)
}
// Unmarshal and import
var importData CharacterExport
err = json.Unmarshal(jsonData, &importData)
if err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
importData.Character.ID = 0
importData.Character.Name = "JSON Imported"
charID, err := ImportCharacter(&importData, 1)
if err != nil {
t.Fatalf("ImportCharacter from JSON failed: %v", err)
}
if charID == 0 {
t.Error("Expected non-zero character ID")
}
}
+20
View File
@@ -0,0 +1,20 @@
package transfer
import (
"github.com/gin-gonic/gin"
)
// RegisterRoutes registers the transfer routes
func RegisterRoutes(r *gin.RouterGroup) {
transfer := r.Group("/transfer")
{
// Export character as JSON (for API consumption)
transfer.GET("/export/:id", ExportCharacterHandler)
// Download character as JSON file
transfer.GET("/download/:id", DownloadCharacterHandler)
// Import character from JSON
transfer.POST("/import", ImportCharacterHandler)
}
}