Importer Exporter Bamort style
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user