Files
bamort/backend/importer/e2e_test.go
T

549 lines
16 KiB
Go

package importer
import (
"bamort/database"
"bamort/models"
"bamort/user"
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestE2E_CompleteImportWorkflow tests the full user workflow from file upload to database
func TestE2E_CompleteImportWorkflow(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
db := database.DB
// Clean up before test to ensure fresh state
db.Exec("DELETE FROM import_histories")
db.Exec("DELETE FROM master_data_imports")
db.Exec("DELETE FROM chars")
db.Exec("DELETE FROM users")
// Run migrations
err := models.MigrateStructure(db)
require.NoError(t, err)
err = MigrateStructure(db)
require.NoError(t, err)
// Create test user
testUser := &user.User{
Username: "testuser",
Email: "test@example.com",
PasswordHash: "hashedpassword",
}
err = db.Create(testUser).Error
require.NoError(t, err)
// Mock adapter server
mockAdapter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/metadata":
metadata := map[string]interface{}{
"id": "test-adapter-v1",
"name": "Test Adapter",
"version": "1.0",
"bmrt_versions": []string{"1.0"},
"supported_extensions": []string{".json"},
"capabilities": []string{"import", "export", "detect"},
}
json.NewEncoder(w).Encode(metadata)
case "/detect":
response := map[string]interface{}{
"confidence": 0.95,
"version": "1.0",
}
json.NewEncoder(w).Encode(response)
case "/import":
// Return minimal BMRT format
bmrt := CharacterImport{
Name: "E2E Test Character",
Grad: 1,
Rasse: "Mensch",
Typ: "Krieger",
Alter: 25,
Eigenschaften: Eigenschaften{
St: 80, Gs: 75, Gw: 70, Ko: 85,
In: 65, Zt: 60, Pa: 55, Au: 70, Wk: 60,
},
Lp: Lp{Max: 12, Value: 12},
Ap: Ap{Max: 20, Value: 20},
}
json.NewEncoder(w).Encode(bmrt)
case "/export":
// Return simple JSON
response := map[string]string{"name": "E2E Test Character"}
json.NewEncoder(w).Encode(response)
default:
http.NotFound(w, r)
}
}))
defer mockAdapter.Close()
// Register mock adapter
registry := NewAdapterRegistry()
err = registry.Register(AdapterMetadata{
ID: "test-adapter-v1",
Name: "Test Adapter",
Version: "1.0",
BmrtVersions: []string{"1.0"},
SupportedExtensions: []string{".json"},
BaseURL: mockAdapter.URL,
Capabilities: []string{"import", "export", "detect"},
Healthy: true,
LastCheckedAt: time.Now(),
})
require.NoError(t, err)
// Set up Gin router
gin.SetMode(gin.TestMode)
router := gin.New()
protected := router.Group("/api")
protected.Use(func(c *gin.Context) {
c.Set("userID", testUser.UserID)
c.Next()
})
// Register routes with our mock registry
registerRoutesWithRegistry(protected, registry)
// STEP 1: Upload file and detect format
t.Run("DetectFormat", func(t *testing.T) {
body, contentType := createMultipartFile(t, "file", "test.json", []byte(`{"test": "data"}`))
req := httptest.NewRequest("POST", "/api/import/detect", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "test-adapter-v1", response["adapter_id"])
assert.Greater(t, response["confidence"], 0.9)
})
// STEP 2: Import character
var importID uint
var characterID uint
t.Run("ImportCharacter", func(t *testing.T) {
body, contentType := createMultipartFile(t, "file", "test.json", []byte(`{"test": "data"}`))
req := httptest.NewRequest("POST", "/api/import/import", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response ImportResult
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotZero(t, response.CharacterID)
assert.NotZero(t, response.ImportID)
assert.Equal(t, "test-adapter-v1", response.AdapterID)
assert.Equal(t, "success", response.Status)
importID = response.ImportID
characterID = response.CharacterID
})
// STEP 3: Verify character in database
t.Run("VerifyCharacterInDB", func(t *testing.T) {
var char models.Char
err := db.Where("id = ?", characterID).First(&char).Error
require.NoError(t, err)
assert.Equal(t, "E2E Test Character", char.Name)
assert.Equal(t, 1, char.Grad)
assert.Equal(t, testUser.UserID, char.UserID)
assert.NotNil(t, char.ImportedFromAdapter)
assert.Equal(t, "test-adapter-v1", *char.ImportedFromAdapter)
assert.NotNil(t, char.ImportedAt)
})
// STEP 4: Verify import history
t.Run("VerifyImportHistory", func(t *testing.T) {
var history ImportHistory
err := db.Where("id = ?", importID).First(&history).Error
require.NoError(t, err)
assert.Equal(t, testUser.UserID, history.UserID)
require.NotNil(t, history.CharacterID)
assert.Equal(t, characterID, *history.CharacterID)
assert.Equal(t, "test-adapter-v1", history.AdapterID)
assert.Equal(t, "success", history.Status)
assert.NotEmpty(t, history.SourceSnapshot)
assert.Equal(t, "1.0", history.BmrtVersion)
})
// STEP 5: List import history
t.Run("ListImportHistory", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/import/history", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response struct {
Histories []ImportHistory `json:"histories"`
Total int64 `json:"total"`
}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, int64(1), response.Total)
assert.Len(t, response.Histories, 1)
assert.Equal(t, importID, response.Histories[0].ID)
})
// STEP 6: Export character back to original format
t.Run("ExportCharacter", func(t *testing.T) {
req := httptest.NewRequest("POST", fmt.Sprintf("/api/import/export/%d", characterID), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
assert.Contains(t, w.Header().Get("Content-Disposition"), "attachment")
var exported map[string]string
err := json.Unmarshal(w.Body.Bytes(), &exported)
require.NoError(t, err)
assert.Equal(t, "E2E Test Character", exported["name"])
})
// STEP 7: List available adapters
t.Run("ListAdapters", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/import/adapters", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response struct {
Adapters []AdapterMetadata `json:"adapters"`
}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Len(t, response.Adapters, 1)
assert.Equal(t, "test-adapter-v1", response.Adapters[0].ID)
})
t.Cleanup(func() {
db.Exec("DELETE FROM import_histories")
db.Exec("DELETE FROM master_data_imports")
db.Exec("DELETE FROM chars")
db.Exec("DELETE FROM users")
})
}
// TestE2E_ImportWithMasterDataReconciliation tests master data creation during import
func TestE2E_ImportWithMasterDataReconciliation(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
db := database.DB
err := models.MigrateStructure(db)
require.NoError(t, err)
err = MigrateStructure(db)
require.NoError(t, err)
// Create Midgard5 game system for testing (required for master data reconciliation)
gameSystem := &models.GameSystem{
Code: "Midgard5",
Name: "Midgard 5",
Description: "Midgard 5th Edition",
}
err = db.Create(gameSystem).Error
require.NoError(t, err)
testUser := &user.User{
Username: "testuser2",
Email: "test2@example.com",
PasswordHash: "hashedpassword",
}
err = db.Create(testUser).Error
require.NoError(t, err)
// Mock adapter with skills and spells
mockAdapter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/metadata":
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "test-adapter-v2",
"bmrt_versions": []string{"1.0"},
"supported_extensions": []string{".json"},
"capabilities": []string{"import", "detect"},
})
case "/detect":
json.NewEncoder(w).Encode(map[string]interface{}{"confidence": 0.95})
case "/import":
bmrt := CharacterImport{
Name: "Character with Skills",
Grad: 2,
Rasse: "Elf",
Typ: "Magier",
Alter: 100,
Eigenschaften: Eigenschaften{St: 80, Gs: 75, Gw: 70, Ko: 85, In: 65, Zt: 60, Pa: 55, Au: 70, Wk: 60},
Lp: Lp{Max: 10, Value: 10},
Ap: Ap{Max: 30, Value: 30},
Fertigkeiten: []Fertigkeit{
{ImportBase: ImportBase{Name: "Custom Skill 1"}, Fertigkeitswert: 12},
{ImportBase: ImportBase{Name: "Custom Skill 2"}, Fertigkeitswert: 15},
},
Zauber: []Zauber{
{ImportBase: ImportBase{Name: "Custom Spell"}, Bonus: 10},
},
}
json.NewEncoder(w).Encode(bmrt)
}
}))
defer mockAdapter.Close()
registry := NewAdapterRegistry()
err = registry.Register(AdapterMetadata{
ID: "test-adapter-v2",
BaseURL: mockAdapter.URL,
BmrtVersions: []string{"1.0"},
SupportedExtensions: []string{".json"},
Capabilities: []string{"import", "detect"},
Healthy: true,
})
require.NoError(t, err)
gin.SetMode(gin.TestMode)
router := gin.New()
protected := router.Group("/api")
protected.Use(func(c *gin.Context) {
c.Set("userID", testUser.UserID)
c.Next()
})
registerRoutesWithRegistry(protected, registry)
// Import character
body, contentType := createMultipartFile(t, "file", "test.json", []byte(`{}`))
req := httptest.NewRequest("POST", "/api/import/import", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Logf("Error response: %s", w.Body.String())
}
assert.Equal(t, http.StatusOK, w.Code)
var response ImportResult
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Verify master data imports were logged
var masterDataCount int64
db.Model(&MasterDataImport{}).Where("import_history_id = ?", response.ImportID).Count(&masterDataCount)
assert.Greater(t, masterDataCount, int64(0), "Should have logged master data imports")
t.Cleanup(func() {
db.Exec("DELETE FROM import_histories")
db.Exec("DELETE FROM master_data_imports")
db.Exec("DELETE FROM chars")
db.Exec("DELETE FROM users")
})
}
// TestE2E_ImportFailureRollback tests that failed imports rollback correctly
func TestE2E_ImportFailureRollback(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
db := database.DB
err := models.MigrateStructure(db)
require.NoError(t, err)
err = MigrateStructure(db)
require.NoError(t, err)
testUser := &user.User{
Username: "testuser3",
Email: "test3@example.com",
PasswordHash: "hashedpassword",
}
err = db.Create(testUser).Error
require.NoError(t, err)
// Mock adapter that returns invalid data
mockAdapter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/metadata":
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "test-adapter-v3",
"bmrt_versions": []string{"1.0"},
"supported_extensions": []string{".json"},
"capabilities": []string{"import"},
})
case "/import":
// Return invalid BMRT (missing required fields)
bmrt := CharacterImport{
Name: "", // Invalid: empty name
Grad: 0,
}
json.NewEncoder(w).Encode(bmrt)
}
}))
defer mockAdapter.Close()
registry := NewAdapterRegistry()
err = registry.Register(AdapterMetadata{
ID: "test-adapter-v3",
BaseURL: mockAdapter.URL,
BmrtVersions: []string{"1.0"},
Capabilities: []string{"import"},
Healthy: true,
})
require.NoError(t, err)
gin.SetMode(gin.TestMode)
router := gin.New()
protected := router.Group("/api")
protected.Use(func(c *gin.Context) {
c.Set("userID", testUser.UserID)
c.Next()
})
registerRoutesWithRegistry(protected, registry)
// Attempt import
body, contentType := createMultipartFile(t, "file", "test.json", []byte(`{}`))
req := httptest.NewRequest("POST", "/api/import/import", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should fail with validation error
assert.NotEqual(t, http.StatusOK, w.Code)
// Verify no character was created
var charCount int64
db.Model(&models.Char{}).Where("user_id = ?", testUser.UserID).Count(&charCount)
assert.Equal(t, int64(0), charCount, "No character should be created on failed import")
t.Cleanup(func() {
db.Exec("DELETE FROM import_histories")
db.Exec("DELETE FROM chars")
db.Exec("DELETE FROM users")
})
}
// TestE2E_UnhealthyAdapterHandling tests graceful handling of unavailable adapters
func TestE2E_UnhealthyAdapterHandling(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB(true)
db := database.DB
err := models.MigrateStructure(db)
require.NoError(t, err)
err = MigrateStructure(db)
require.NoError(t, err)
registry := NewAdapterRegistry()
err = registry.Register(AdapterMetadata{
ID: "unhealthy-adapter",
BaseURL: "http://localhost:9999", // Non-existent
BmrtVersions: []string{"1.0"},
Capabilities: []string{"import"},
Healthy: false, // Mark as unhealthy
})
require.NoError(t, err)
gin.SetMode(gin.TestMode)
router := gin.New()
protected := router.Group("/api")
protected.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Next()
})
registerRoutesWithRegistry(protected, registry)
// Attempt to use unhealthy adapter
body, contentType := createMultipartFile(t, "file", "test.json", []byte(`{}`))
req := httptest.NewRequest("POST", "/api/import/import?adapter_id=unhealthy-adapter", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response["error"], "unavailable")
}
// Helper function to create multipart file
func createMultipartFile(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, filename)
require.NoError(t, err)
_, err = io.Copy(part, bytes.NewReader(content))
require.NoError(t, err)
err = writer.Close()
require.NoError(t, err)
return body, writer.FormDataContentType()
}
// Helper function to register routes with custom registry
func registerRoutesWithRegistry(r *gin.RouterGroup, registry *AdapterRegistry) {
globalRegistry = registry // Set global registry
RegisterRoutes(r)
}
// TestE2E_RoundTripExportImport tests that exported data can be reimported
func TestE2E_RoundTripExportImport(t *testing.T) {
t.Skip("Requires full adapter implementation with export support")
// This test would:
// 1. Import a character
// 2. Export it to original format
// 3. Import the exported file again
// 4. Verify both characters are identical
}
// TestE2E_ConcurrentImports tests multiple simultaneous imports
func TestE2E_ConcurrentImports(t *testing.T) {
t.Skip("Stress test - run separately")
// This test would simulate multiple users importing at the same time
// to verify thread safety and database transaction handling
}
// TestE2E_LargeFileImport tests import of large character files
func TestE2E_LargeFileImport(t *testing.T) {
t.Skip("Performance test - run separately")
// This test would import a character with hundreds of skills/spells
// to verify performance and memory handling
}