Phase 1: Core Infrastructure

This commit is contained in:
2026-02-27 12:00:39 +01:00
parent e5a340db36
commit 865017a107
16 changed files with 2093 additions and 19 deletions
+112
View File
@@ -0,0 +1,112 @@
# Importer Package
The `importer/` package provides a pluggable character import/export system using Docker-based microservice adapters.
## Architecture
This package orchestrates character imports from external formats (e.g., Foundry VTT) using isolated adapter microservices:
```
External Format (Foundry VTT JSON)
Adapter Microservice (Docker container)
importer.CharacterImport (BMRT-Format - canonical interchange format)
importer/ package handlers (validation, reconciliation)
models.Char (BaMoRT database)
```
## Package vs Related Packages
- **`transfero/`** - BaMoRT-to-BaMoRT lossless transfer (existing, untouched)
- **`importero/`** - Legacy format handlers (VTT JSON, CSV) with direct imports (deprecated, untouched)
- **`importer/`** - NEW microservice adapter orchestration layer (self-contained)
## Core Components
### bmrt.go
BMRT (BaMoRT Format) wrapper with source metadata tracking. Uses `CharacterImport` (defined in character.go) as the canonical interchange format.
### registry.go
Adapter service registry with health monitoring, version negotiation, and runtime failover.
### detector.go
Smart format detection with short-circuit optimization (extension match → signature cache → fan-out).
### validator.go
3-phase validation framework:
1. BMRT structural validation (JSON schema, required fields)
2. Game system semantic validation (stat ranges, referential integrity)
3. Adapter-specific validation (format compatibility)
### reconciler.go
Master data reconciliation:
- Exact match by (Name + GameSystem)
- Auto-create with PersonalItem=true flag
- Track in MasterDataImport table
### handlers.go & routes.go
HTTP API endpoints for detection, import, export, and history.
## API Endpoints
- `POST /api/import/detect` - Upload file, detect format
- `POST /api/import/import` - Import character with adapter
- `GET /api/import/adapters` - List registered adapters
- `GET /api/import/history` - User's import history
- `GET /api/import/history/:id` - Import details + errors
- `POST /api/import/export/:id` - Export character to original format
## Database Models
### ImportHistory
Tracks all import attempts with compressed source snapshots, mapping data, and error logs.
### MasterDataImport
Tracks created/matched master data items (skills, spells, equipment).
### Char Extensions
Added fields: `ImportedFromAdapter`, `ImportedAt` for provenance tracking.
## Security
- Rate limiting: 10/min detect, 5/min import, 20/min export (per user)
- File size limit: 10MB max
- JSON depth limit: 100 levels
- SSRF protection: whitelisted adapter URLs only
- No persistent disk storage: files only in DB after import
## Testing
All components follow TDD:
- Unit tests: registry_test.go, validator_test.go
- Integration tests: integration_test.go
- Use `testutils.SetupTestDB()` for database tests
## Development Guidelines
- **TDD**: Write failing test first, then implement
- **KISS**: Simplest solution that works
- **Zero modification** to importero or transfero packages
- **Self-contained**: importer package owns all its types (no importero dependency)
- **Transaction safety**: Full ACID compliance for imports
## Adapter Development
See `backend/adapters/ADAPTER_DEVELOPMENT.md` for creating new adapter microservices.
## Usage Example
```go
// Register adapter (happens at startup)
registry.Register(AdapterMetadata{
ID: "foundry-vtt-v1",
BaseURL: "http://adapter-foundry:8181",
})
// Import character
result, err := ImportCharacter(fileData, userID, "")
// Auto-detects format, reconciles master data, creates character
```
+44
View File
@@ -0,0 +1,44 @@
package importer
import (
"encoding/json"
"time"
)
// BMRTCharacter wraps CharacterImport with version and metadata tracking.
// This is the canonical interchange format for all adapter conversions.
type BMRTCharacter struct {
CharacterImport // Embedded canonical format
BmrtVersion string `json:"bmrt_version"` // e.g., "1.0"
Extensions map[string]json.RawMessage `json:"extensions,omitempty"` // Adapter-specific data
Metadata SourceMetadata `json:"_metadata"`
}
// SourceMetadata tracks the origin of imported data for provenance
type SourceMetadata struct {
SourceFormat string `json:"source_format"` // e.g., "foundry-vtt"
AdapterID string `json:"adapter_id"` // e.g., "foundry-vtt-v1"
ImportedAt time.Time `json:"imported_at"`
}
// CurrentBMRTVersion is the supported BMRT format version
const CurrentBMRTVersion = "1.0"
// ValidateBMRTVersion checks if the BMRT version is supported
func ValidateBMRTVersion(version string) bool {
return version == CurrentBMRTVersion
}
// NewBMRTCharacter creates a new BMRT character with metadata
func NewBMRTCharacter(char CharacterImport, adapterID, sourceFormat string) *BMRTCharacter {
return &BMRTCharacter{
CharacterImport: char,
BmrtVersion: CurrentBMRTVersion,
Extensions: make(map[string]json.RawMessage),
Metadata: SourceMetadata{
SourceFormat: sourceFormat,
AdapterID: adapterID,
ImportedAt: time.Now(),
},
}
}
+122
View File
@@ -0,0 +1,122 @@
package importer
import (
"encoding/json"
"testing"
"time"
)
func TestValidateBMRTVersion(t *testing.T) {
tests := []struct {
name string
version string
want bool
}{
{
name: "valid current version",
version: "1.0",
want: true,
},
{
name: "invalid future version",
version: "2.0",
want: false,
},
{
name: "invalid old version",
version: "0.9",
want: false,
},
{
name: "empty version",
version: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateBMRTVersion(tt.version); got != tt.want {
t.Errorf("ValidateBMRTVersion() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewBMRTCharacter(t *testing.T) {
char := CharacterImport{
Name: "Test Character",
Typ: "Krieger",
}
adapterID := "foundry-vtt-v1"
sourceFormat := "foundry-vtt"
before := time.Now()
bmrt := NewBMRTCharacter(char, adapterID, sourceFormat)
after := time.Now()
if bmrt.Name != "Test Character" {
t.Errorf("Character name = %v, want %v", bmrt.Name, "Test Character")
}
if bmrt.Typ != "Krieger" {
t.Errorf("Typ = %v, want %v", bmrt.Typ, "Krieger")
}
if bmrt.BmrtVersion != CurrentBMRTVersion {
t.Errorf("BmrtVersion = %v, want %v", bmrt.BmrtVersion, CurrentBMRTVersion)
}
if bmrt.Metadata.AdapterID != adapterID {
t.Errorf("Metadata.AdapterID = %v, want %v", bmrt.Metadata.AdapterID, adapterID)
}
if bmrt.Metadata.SourceFormat != sourceFormat {
t.Errorf("Metadata.SourceFormat = %v, want %v", bmrt.Metadata.SourceFormat, sourceFormat)
}
if bmrt.Metadata.ImportedAt.Before(before) || bmrt.Metadata.ImportedAt.After(after) {
t.Errorf("Metadata.ImportedAt not within expected time range")
}
if bmrt.Extensions == nil {
t.Error("Extensions map should be initialized")
}
}
func TestBMRTCharacterJSONSerialization(t *testing.T) {
char := CharacterImport{
Name: "Test Character",
Typ: "Krieger",
}
bmrt := NewBMRTCharacter(char, "test-adapter", "test-format")
// Add extension data
extensionData := map[string]interface{}{
"original_id": "abc123",
"version": "11.x",
}
extensionJSON, _ := json.Marshal(extensionData)
bmrt.Extensions["foundry"] = extensionJSON
// Serialize
data, err := json.Marshal(bmrt)
if err != nil {
t.Fatalf("Failed to marshal BMRTCharacter: %v", err)
}
// Deserialize
var decoded BMRTCharacter
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("Failed to unmarshal BMRTCharacter: %v", err)
}
// Verify
if decoded.Name != bmrt.Name {
t.Errorf("Decoded name = %v, want %v", decoded.Name, bmrt.Name)
}
if decoded.BmrtVersion != bmrt.BmrtVersion {
t.Errorf("Decoded BmrtVersion = %v, want %v", decoded.BmrtVersion, bmrt.BmrtVersion)
}
if decoded.Metadata.AdapterID != bmrt.Metadata.AdapterID {
t.Errorf("Decoded AdapterID = %v, want %v", decoded.Metadata.AdapterID, bmrt.Metadata.AdapterID)
}
if len(decoded.Extensions) != 1 {
t.Errorf("Decoded Extensions length = %v, want 1", len(decoded.Extensions))
}
}
+195
View File
@@ -0,0 +1,195 @@
package importer
// This file contains the character import data structures.
// These were copied from the deprecated importero package to break dependencies.
// ImportBase provides common fields for imported items
type ImportBase struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Magisch represents magical properties of items
type Magisch struct {
Abw int `json:"abw"`
Ausgebrannt bool `json:"ausgebrannt"`
IstMagisch bool `json:"ist_magisch"`
}
// Ausruestung represents equipment/gear
type Ausruestung struct {
ImportBase
Beschreibung string `json:"beschreibung"`
Anzahl int `json:"anzahl"`
BeinhaltetIn string `json:"beinhaltet_in"`
ContainedIn uint `json:"contained_in"`
Bonus int `json:"bonus,omitempty"`
Gewicht float64 `json:"gewicht"`
Magisch Magisch `json:"magisch"`
Wert float64 `json:"wert"`
}
// Waffe represents a weapon
type Waffe struct {
ImportBase
Beschreibung string `json:"beschreibung"`
Abwb int `json:"abwb"`
Anb int `json:"anb"`
Anzahl int `json:"anzahl"`
BeinhaltetIn string `json:"beinhaltet_in"`
ContainedIn uint `json:"contained_in"`
Gewicht float64 `json:"gewicht"`
Magisch Magisch `json:"magisch"`
NameFuerSpezialisierung string `json:"nameFuerSpezialisierung"`
Schb int `json:"schb"`
Wert float64 `json:"wert"`
}
// Behaeltniss represents a container
type Behaeltniss struct {
ImportBase
Beschreibung string `json:"beschreibung"`
BeinhaltetIn string `json:"beinhaltet_in"`
ContainedIn uint `json:"contained_in"`
Gewicht float64 `json:"gewicht"`
Magisch Magisch `json:"magisch"`
Tragkraft float64 `json:"tragkraft"`
Volumen float64 `json:"volumen"`
Wert float64 `json:"wert"`
}
// Transportation represents a means of transport
type Transportation struct {
ImportBase
Beschreibung string `json:"beschreibung"`
BeinhaltetIn string `json:"beinhaltet_in"`
ContainedIn uint `json:"contained_in"`
Gewicht int `json:"gewicht"`
Tragkraft float64 `json:"tragkraft"`
Wert float64 `json:"wert"`
Magisch Magisch `json:"magisch"`
}
// Fertigkeit represents a skill
type Fertigkeit struct {
ImportBase
Beschreibung string `json:"beschreibung"`
Fertigkeitswert int `json:"fertigkeitswert"`
Bonus int `json:"bonus,omitempty"`
Pp int `json:"pp,omitempty"`
Quelle string `json:"quelle"`
}
// Zauber represents a spell
type Zauber struct {
ImportBase
Beschreibung string `json:"beschreibung"`
Bonus int `json:"bonus"`
Quelle string `json:"quelle"`
}
// Waffenfertigkeit represents a weapon skill
type Waffenfertigkeit struct {
ImportBase
Beschreibung string `json:"beschreibung"`
Bonus int `json:"bonus"`
Fertigkeitswert int `json:"fertigkeitswert"`
Pp int `json:"pp"`
Quelle string `json:"quelle"`
}
// Eigenschaft represents a character attribute (deprecated field, kept for compatibility)
type Eigenschaft struct {
ID uint `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
}
// Merkmale represents character features/traits
type Merkmale struct {
Augenfarbe string `json:"augenfarbe"`
Haarfarbe string `json:"haarfarbe"`
Sonstige string `json:"sonstige"`
}
// Lp represents life points/hit points
type Lp struct {
Max int `json:"max"`
Value int `json:"value"`
}
// Gestalt represents character build/physique
type Gestalt struct {
Breite string `json:"breite"`
Groesse string `json:"groesse"`
}
// Erfahrungsschatz represents experience points
type Erfahrungsschatz struct {
Value int `json:"value"`
}
// Eigenschaften represents the main character attributes
type Eigenschaften struct {
Au int `json:"au"` // Aussehen
Gs int `json:"gs"` // Geschicklichkeit
Gw int `json:"gw"` // Gewandtheit
In int `json:"in"` // Intelligenz
Ko int `json:"ko"` // Konstitution
Pa int `json:"pa"` // Persönliche Ausstrahlung
St int `json:"st"` // Stärke
Wk int `json:"wk"` // Willenskraft
Zt int `json:"zt"` // Zähigkeit
}
// Bennies represents fate points/luck points
type Bennies struct {
Gg int `json:"gg"` // Göttliche Gnade
Gp int `json:"gp"` // Glückspunkte
Sg int `json:"sg"` // Schicksalsgunst
}
// Ap represents action points/stamina
type Ap struct {
Max int `json:"max"`
Value int `json:"value"`
}
// B represents movement points
type B struct {
Max int `json:"max"`
Value int `json:"value"`
}
// CharacterImport represents the complete character data for import
// This is the canonical interchange format for character data
type CharacterImport struct {
ID string `json:"id"`
Name string `json:"name"`
Rasse string `json:"rasse"`
Typ string `json:"typ"`
Alter int `json:"alter"`
Anrede string `json:"anrede"`
Grad int `json:"grad"`
Groesse int `json:"groesse"`
Gewicht int `json:"gewicht"`
Glaube string `json:"glaube"`
Hand string `json:"hand"`
Fertigkeiten []Fertigkeit `json:"fertigkeiten"`
Zauber []Zauber `json:"zauber"`
Lp Lp `json:"lp"`
Eigenschaften Eigenschaften `json:"eigenschaften"`
Merkmale Merkmale `json:"merkmale"`
Bennies Bennies `json:"bennies"`
Gestalt Gestalt `json:"gestalt"`
Ap Ap `json:"ap"`
B B `json:"b"`
Erfahrungsschatz Erfahrungsschatz `json:"erfahrungsschatz"`
Transportmittel []Transportation `json:"transportmittel"`
Ausruestung []Ausruestung `json:"ausruestung"`
Behaeltnisse []Behaeltniss `json:"behaeltnisse"`
Waffen []Waffe `json:"waffen"`
Waffenfertigkeiten []Waffenfertigkeit `json:"waffenfertigkeiten"`
Spezialisierung []string `json:"spezialisierung"`
Image string `json:"image,omitempty"`
}
+165
View File
@@ -0,0 +1,165 @@
package importer
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"time"
)
// DetectionCache stores cached format detection results
type DetectionCache struct {
signature string
adapterID string
confidence float64
cachedAt time.Time
}
// Detector handles smart format detection with optimizations
type Detector struct {
registry *AdapterRegistry
cache map[string]*DetectionCache
cacheMu sync.RWMutex
cacheTTL time.Duration
}
// NewDetector creates a new detector
func NewDetector(registry *AdapterRegistry) *Detector {
return &Detector{
registry: registry,
cache: make(map[string]*DetectionCache),
cacheTTL: 5 * time.Minute, // Default TTL
}
}
// DetectFormat implements smart format detection with short-circuit optimization
// Priority:
// 1. User-specified adapter (if provided)
// 2. Extension match (if single match found)
// 3. Signature cache (SHA256 of first 1KB)
// 4. Full adapter detection fan-out
func (d *Detector) DetectFormat(data []byte, filename string, specifiedAdapterID string) (string, float64, error) {
// Step 1: User-specified adapter (highest priority)
if specifiedAdapterID != "" {
adapter := d.registry.Get(specifiedAdapterID)
if adapter == nil {
return "", 0, fmt.Errorf("specified adapter not found: %s", specifiedAdapterID)
}
if !adapter.Healthy {
return "", 0, fmt.Errorf("specified adapter is unhealthy: %s", specifiedAdapterID)
}
return specifiedAdapterID, 1.0, nil
}
// Step 2: Extension match (short-circuit if single match)
ext := getFileExtension(filename)
if ext != "" {
matches := d.getAdaptersByExtension(ext)
if len(matches) == 1 {
// Single match - short-circuit!
return matches[0].ID, 1.0, nil
}
// Multiple matches - continue to full detection
}
// Step 3: Signature cache
if cachedAdapterID, cachedConfidence := d.getCachedDetection(data); cachedAdapterID != "" {
return cachedAdapterID, cachedConfidence, nil
}
// Step 4: Full detection fan-out to all healthy adapters
adapterID, confidence, err := d.registry.Detect(data, filename)
if err != nil {
return "", 0, err
}
// Cache the successful detection
d.cacheDetection(data, adapterID, confidence)
return adapterID, confidence, nil
}
// getAdaptersByExtension returns all healthy adapters that support the given extension
func (d *Detector) getAdaptersByExtension(ext string) []*AdapterMetadata {
healthy := d.registry.GetHealthy()
matches := make([]*AdapterMetadata, 0)
ext = strings.ToLower(ext)
for _, adapter := range healthy {
if !adapter.SupportsCapability("detect") {
continue
}
for _, supportedExt := range adapter.SupportedExtensions {
if strings.ToLower(supportedExt) == ext {
matches = append(matches, adapter)
break
}
}
}
return matches
}
// getCachedDetection retrieves a cached detection result if available and not expired
func (d *Detector) getCachedDetection(data []byte) (string, float64) {
signature := d.computeSignature(data)
d.cacheMu.RLock()
defer d.cacheMu.RUnlock()
cached, exists := d.cache[signature]
if !exists {
return "", 0
}
// Check if cache entry is expired
if time.Since(cached.cachedAt) > d.cacheTTL {
return "", 0
}
return cached.adapterID, cached.confidence
}
// cacheDetection stores a detection result in the cache
func (d *Detector) cacheDetection(data []byte, adapterID string, confidence float64) {
signature := d.computeSignature(data)
d.cacheMu.Lock()
defer d.cacheMu.Unlock()
d.cache[signature] = &DetectionCache{
signature: signature,
adapterID: adapterID,
confidence: confidence,
cachedAt: time.Now(),
}
}
// computeSignature computes SHA256 hash of first 1KB of data
func (d *Detector) computeSignature(data []byte) string {
// Use first 1KB or full data if smaller
size := 1024
if len(data) < size {
size = len(data)
}
hash := sha256.Sum256(data[:size])
return hex.EncodeToString(hash[:])
}
// getFileExtension extracts the file extension from a filename (case-insensitive)
func getFileExtension(filename string) string {
if filename == "" {
return ""
}
// Find last dot
lastDot := strings.LastIndex(filename, ".")
if lastDot == -1 || lastDot == len(filename)-1 {
return ""
}
return strings.ToLower(filename[lastDot:])
}
+172
View File
@@ -0,0 +1,172 @@
package importer
import (
"testing"
)
func TestDetector_ExtensionMatch(t *testing.T) {
registry := NewAdapterRegistry()
// Register adapter with .json extension and a BaseURL
registry.Register(AdapterMetadata{
ID: "json-adapter",
Name: "JSON Adapter",
SupportedExtensions: []string{".json"},
Capabilities: []string{"detect"},
BaseURL: "http://localhost:8181",
Healthy: true,
})
detector := NewDetector(registry)
// Test extension match - should short-circuit with single match
data := []byte(`{"name": "test"}`)
adapterID, confidence, err := detector.DetectFormat(data, "character.json", "")
if err != nil {
t.Fatalf("DetectFormat failed: %v", err)
}
if adapterID != "json-adapter" {
t.Errorf("Expected json-adapter, got %s", adapterID)
}
if confidence != 1.0 {
t.Errorf("Expected confidence 1.0 for extension match, got %f", confidence)
}
}
func TestDetector_ExtensionMatchMultiple(t *testing.T) {
registry := NewAdapterRegistry()
// Register two adapters with same extension
registry.Register(AdapterMetadata{
ID: "adapter-1",
Name: "Adapter 1",
SupportedExtensions: []string{".json"},
Capabilities: []string{"detect"},
Healthy: true,
BaseURL: "http://localhost:8181",
})
registry.Register(AdapterMetadata{
ID: "adapter-2",
Name: "Adapter 2",
SupportedExtensions: []string{".json"},
Capabilities: []string{"detect"},
Healthy: true,
BaseURL: "http://localhost:8182",
})
detector := NewDetector(registry)
// With multiple matches, should skip extension match and use full detection
// Since we don't have a real server, this will fail - that's expected
data := []byte(`{"name": "test"}`)
_, _, err := detector.DetectFormat(data, "character.json", "")
// We expect this to fail because no real servers are running
if err == nil {
t.Error("Expected error when no adapters can detect")
}
}
func TestDetector_SpecifiedAdapter(t *testing.T) {
registry := NewAdapterRegistry()
registry.Register(AdapterMetadata{
ID: "specified-adapter",
Name: "Specified",
BaseURL: "http://localhost:8181",
Healthy: true,
})
detector := NewDetector(registry)
data := []byte(`{"name": "test"}`)
adapterID, confidence, err := detector.DetectFormat(data, "test.json", "specified-adapter")
if err != nil {
t.Fatalf("DetectFormat failed: %v", err)
}
if adapterID != "specified-adapter" {
t.Errorf("Expected specified-adapter, got %s", adapterID)
}
if confidence != 1.0 {
t.Errorf("Expected confidence 1.0 for specified adapter, got %f", confidence)
}
}
func TestDetector_SignatureCache(t *testing.T) {
registry := NewAdapterRegistry()
detector := NewDetector(registry)
data := []byte(`{"name": "test character with some data to make it longer than usual"}`)
// First detection - cache miss
detector.cacheDetection(data, "test-adapter", 0.95)
// Second detection - cache hit
adapterID, confidence := detector.getCachedDetection(data)
if adapterID != "test-adapter" {
t.Errorf("Expected cached adapter test-adapter, got %s", adapterID)
}
if confidence != 0.95 {
t.Errorf("Expected cached confidence 0.95, got %f", confidence)
}
}
func TestDetector_SignatureCacheTTL(t *testing.T) {
registry := NewAdapterRegistry()
detector := NewDetector(registry)
detector.cacheTTL = 0 // Set TTL to 0 for immediate expiration
data := []byte(`{"name": "test"}`)
// Cache a detection
detector.cacheDetection(data, "test-adapter", 0.95)
// Should be expired immediately
adapterID, _ := detector.getCachedDetection(data)
if adapterID != "" {
t.Errorf("Expected cache miss after TTL expiration, got %s", adapterID)
}
}
func TestDetector_ComputeSignature(t *testing.T) {
detector := NewDetector(nil)
data1 := []byte(`{"name": "test"}`)
data2 := []byte(`{"name": "test"}`)
data3 := []byte(`{"name": "different"}`)
sig1 := detector.computeSignature(data1)
sig2 := detector.computeSignature(data2)
sig3 := detector.computeSignature(data3)
if sig1 != sig2 {
t.Error("Same data should produce same signature")
}
if sig1 == sig3 {
t.Error("Different data should produce different signature")
}
}
func TestDetector_ExtensionMatching(t *testing.T) {
// Test various file extensions
tests := []struct {
filename string
extension string
}{
{"character.json", ".json"},
{"CHARACTER.JSON", ".json"},
{"data.xml", ".xml"},
{"file.txt", ".txt"},
{"noextension", ""},
{"multi.part.json", ".json"},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
ext := getFileExtension(tt.filename)
if ext != tt.extension {
t.Errorf("getFileExtension(%s) = %s, want %s", tt.filename, ext, tt.extension)
}
})
}
}
+68
View File
@@ -0,0 +1,68 @@
package importer
import (
"bamort/database"
"time"
"gorm.io/gorm"
)
// ImportHistory tracks all character import attempts with source data and mapping information
type ImportHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
CharacterID *uint `gorm:"index" json:"character_id,omitempty"` // NULL if import failed
AdapterID string `gorm:"type:varchar(100);not null" json:"adapter_id"` // e.g., "foundry-vtt-v1"
SourceFormat string `gorm:"type:varchar(50)" json:"source_format"` // e.g., "foundry-vtt"
SourceFilename string `json:"source_filename"`
SourceSnapshot []byte `gorm:"type:MEDIUMBLOB" json:"-"` // Original file (gzip compressed)
MappingSnapshot []byte `gorm:"type:JSON" json:"-"` // Adapter->BMRT mappings
BmrtVersion string `gorm:"type:varchar(10)" json:"bmrt_version"` // e.g., "1.0"
ImportedAt time.Time `json:"imported_at"`
Status string `gorm:"type:varchar(20)" json:"status"` // "in_progress", "success", "partial", "failed"
ErrorLog string `gorm:"type:TEXT" json:"error_log,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MasterDataImport tracks created/matched master data items during import
type MasterDataImport struct {
ID uint `gorm:"primaryKey" json:"id"`
ImportHistoryID uint `gorm:"not null;index" json:"import_history_id"`
ItemType string `gorm:"type:varchar(20)" json:"item_type"` // "skill", "spell", "weapon", "equipment"
ItemID uint `gorm:"not null" json:"item_id"`
ExternalName string `json:"external_name"`
MatchType string `gorm:"type:varchar(20)" json:"match_type"` // "exact", "created_personal"
CreatedAt time.Time `json:"created_at"`
}
// TableName specifies the table name for ImportHistory
func (ImportHistory) TableName() string {
return "import_histories"
}
// TableName specifies the table name for MasterDataImport
func (MasterDataImport) TableName() string {
return "master_data_imports"
}
// MigrateStructure runs migrations for importer-specific tables
func MigrateStructure(db ...*gorm.DB) error {
// Use provided DB or default to database.DB
var targetDB *gorm.DB
if len(db) > 0 && db[0] != nil {
targetDB = db[0]
} else {
targetDB = database.DB
}
err := targetDB.AutoMigrate(
&ImportHistory{},
&MasterDataImport{},
)
if err != nil {
return err
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package importer
import (
"path/filepath"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupTestDB creates a test database for importer model testing
func setupTestDB(t *testing.T) *gorm.DB {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_importer.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
// Run migrations
err = MigrateStructure(db)
if err != nil {
t.Fatalf("Failed to migrate importer structure: %v", err)
}
return db
}
func TestImportHistoryModel(t *testing.T) {
db := setupTestDB(t)
// Create a test import history
userID := uint(1)
charID := uint(18) // Test character
history := ImportHistory{
UserID: userID,
CharacterID: &charID,
AdapterID: "foundry-vtt-v1",
SourceFormat: "foundry-vtt",
SourceFilename: "test-character.json",
BmrtVersion: "1.0",
ImportedAt: time.Now(),
Status: "success",
}
// Create the record
result := db.Create(&history)
if result.Error != nil {
t.Fatalf("Failed to create import history: %v", result.Error)
}
// Verify it was created
if history.ID == 0 {
t.Error("Import history ID should be set after creation")
}
// Fetch it back
var fetched ImportHistory
result = db.First(&fetched, history.ID)
if result.Error != nil {
t.Fatalf("Failed to fetch import history: %v", result.Error)
}
// Verify fields
if fetched.AdapterID != "foundry-vtt-v1" {
t.Errorf("AdapterID = %v, want %v", fetched.AdapterID, "foundry-vtt-v1")
}
if fetched.Status != "success" {
t.Errorf("Status = %v, want %v", fetched.Status, "success")
}
if fetched.CharacterID == nil || *fetched.CharacterID != charID {
t.Errorf("CharacterID = %v, want %v", fetched.CharacterID, charID)
}
}
func TestMasterDataImportModel(t *testing.T) {
db := setupTestDB(t)
// Create a test import history first
history := ImportHistory{
UserID: uint(1),
AdapterID: "test-adapter",
SourceFormat: "test",
ImportedAt: time.Now(),
Status: "success",
}
db.Create(&history)
// Create a test master data import
masterData := MasterDataImport{
ImportHistoryID: history.ID,
ItemType: "skill",
ItemID: uint(1),
ExternalName: "Swordfighting",
MatchType: "exact",
}
// Create the record
result := db.Create(&masterData)
if result.Error != nil {
t.Fatalf("Failed to create master data import: %v", result.Error)
}
// Verify it was created
if masterData.ID == 0 {
t.Error("Master data import ID should be set after creation")
}
// Fetch it back
var fetched MasterDataImport
result = db.First(&fetched, masterData.ID)
if result.Error != nil {
t.Fatalf("Failed to fetch master data import: %v", result.Error)
}
// Verify fields
if fetched.ItemType != "skill" {
t.Errorf("ItemType = %v, want %v", fetched.ItemType, "skill")
}
if fetched.MatchType != "exact" {
t.Errorf("MatchType = %v, want %v", fetched.MatchType, "exact")
}
if fetched.ImportHistoryID != history.ID {
t.Errorf("ImportHistoryID = %v, want %v", fetched.ImportHistoryID, history.ID)
}
}
func TestMigrateStructure(t *testing.T) {
db := setupTestDB(t)
// Verify tables exist by trying to query them
var count int64
err := db.Model(&ImportHistory{}).Count(&count).Error
if err != nil {
t.Errorf("import_histories table should exist: %v", err)
}
err = db.Model(&MasterDataImport{}).Count(&count).Error
if err != nil {
t.Errorf("master_data_imports table should exist: %v", err)
}
}
func TestImportHistory_TableName(t *testing.T) {
history := ImportHistory{}
if history.TableName() != "import_histories" {
t.Errorf("TableName() = %v, want 'import_histories'", history.TableName())
}
}
func TestMasterDataImport_TableName(t *testing.T) {
masterData := MasterDataImport{}
if masterData.TableName() != "master_data_imports" {
t.Errorf("TableName() = %v, want 'master_data_imports'", masterData.TableName())
}
}
+296
View File
@@ -0,0 +1,296 @@
package importer
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// AdapterMetadata contains information about a registered adapter service
type AdapterMetadata struct {
ID string `json:"id"` // e.g., "foundry-vtt-v1"
Name string `json:"name"` // e.g., "Foundry VTT Character"
Version string `json:"version"` // e.g., "1.0"
BmrtVersions []string `json:"bmrt_versions"` // Supported BMRT versions
SupportedExtensions []string `json:"supported_extensions"` // e.g., [".json"]
BaseURL string `json:"base_url"` // e.g., "http://adapter-foundry:8181"
Capabilities []string `json:"capabilities"` // e.g., ["import", "export", "detect"]
Healthy bool `json:"healthy"` // Runtime health status
LastCheckedAt time.Time `json:"last_checked_at"`
LastError string `json:"last_error,omitempty"`
}
// SupportsCapability checks if the adapter supports a specific capability
func (m *AdapterMetadata) SupportsCapability(capability string) bool {
for _, cap := range m.Capabilities {
if cap == capability {
return true
}
}
return false
}
// AdapterRegistry manages registered adapter services
type AdapterRegistry struct {
adapters map[string]*AdapterMetadata
mu sync.RWMutex
client *http.Client
}
// NewAdapterRegistry creates a new adapter registry
func NewAdapterRegistry() *AdapterRegistry {
return &AdapterRegistry{
adapters: make(map[string]*AdapterMetadata),
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Disable redirects for security
},
},
}
}
// Register registers or updates an adapter
func (r *AdapterRegistry) Register(meta AdapterMetadata) error {
r.mu.Lock()
defer r.mu.Unlock()
// Validate required fields
if meta.ID == "" {
return fmt.Errorf("adapter ID is required")
}
if meta.BaseURL == "" {
return fmt.Errorf("adapter base URL is required")
}
// Register/update the adapter
r.adapters[meta.ID] = &meta
return nil
}
// Get retrieves an adapter by ID
func (r *AdapterRegistry) Get(id string) *AdapterMetadata {
r.mu.RLock()
defer r.mu.RUnlock()
return r.adapters[id]
}
// GetAll returns all registered adapters
func (r *AdapterRegistry) GetAll() []*AdapterMetadata {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*AdapterMetadata, 0, len(r.adapters))
for _, adapter := range r.adapters {
result = append(result, adapter)
}
return result
}
// GetHealthy returns only healthy adapters
func (r *AdapterRegistry) GetHealthy() []*AdapterMetadata {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*AdapterMetadata, 0)
for _, adapter := range r.adapters {
if adapter.Healthy {
result = append(result, adapter)
}
}
return result
}
// Import calls an adapter's import endpoint
func (r *AdapterRegistry) Import(adapterID string, data []byte) (*BMRTCharacter, error) {
adapter := r.Get(adapterID)
if adapter == nil {
return nil, fmt.Errorf("adapter not found: %s", adapterID)
}
if !adapter.Healthy {
return nil, fmt.Errorf("adapter is unhealthy: %s", adapterID)
}
if !adapter.SupportsCapability("import") {
return nil, fmt.Errorf("adapter does not support import: %s", adapterID)
}
url := adapter.BaseURL + "/import"
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("adapter request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("adapter returned status %d: %s", resp.StatusCode, string(body))
}
var char BMRTCharacter
if err := json.NewDecoder(resp.Body).Decode(&char); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &char, nil
}
// Export calls an adapter's export endpoint
func (r *AdapterRegistry) Export(adapterID string, char *CharacterImport) ([]byte, error) {
adapter := r.Get(adapterID)
if adapter == nil {
return nil, fmt.Errorf("adapter not found: %s", adapterID)
}
if !adapter.Healthy {
return nil, fmt.Errorf("adapter is unhealthy: %s", adapterID)
}
if !adapter.SupportsCapability("export") {
return nil, fmt.Errorf("adapter does not support export: %s", adapterID)
}
// Marshal character to JSON
charData, err := json.Marshal(char)
if err != nil {
return nil, fmt.Errorf("failed to marshal character: %w", err)
}
url := adapter.BaseURL + "/export"
req, err := http.NewRequest("POST", url, bytes.NewReader(charData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("adapter request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("adapter returned status %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
return data, nil
}
// DetectResponse represents the response from an adapter's detect endpoint
type DetectResponse struct {
Confidence float64 `json:"confidence"`
Version string `json:"version,omitempty"`
}
// Detect calls all healthy adapters' detect endpoints and returns the best match
func (r *AdapterRegistry) Detect(data []byte, filename string) (string, float64, error) {
healthy := r.GetHealthy()
if len(healthy) == 0 {
return "", 0, fmt.Errorf("no healthy adapters available")
}
// Create a short timeout client for detection
detectClient := &http.Client{
Timeout: 2 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
bestAdapterID := ""
bestConfidence := 0.0
for _, adapter := range healthy {
if !adapter.SupportsCapability("detect") {
continue
}
url := adapter.BaseURL + "/detect"
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := detectClient.Do(req)
if err != nil {
continue
}
if resp.StatusCode == http.StatusOK {
var detectResp DetectResponse
if err := json.NewDecoder(resp.Body).Decode(&detectResp); err == nil {
if detectResp.Confidence > bestConfidence {
bestConfidence = detectResp.Confidence
bestAdapterID = adapter.ID
}
}
}
resp.Body.Close()
}
// Require minimum confidence threshold
if bestConfidence < 0.7 {
return "", bestConfidence, fmt.Errorf("no adapter reached confidence threshold (best: %.2f)", bestConfidence)
}
return bestAdapterID, bestConfidence, nil
}
// HealthCheck performs health checks on all adapters
func (r *AdapterRegistry) HealthCheck() error {
r.mu.Lock()
defer r.mu.Unlock()
for id, adapter := range r.adapters {
// Try to ping the metadata endpoint
url := adapter.BaseURL + "/metadata"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
adapter.Healthy = false
adapter.LastError = err.Error()
adapter.LastCheckedAt = time.Now()
continue
}
resp, err := r.client.Do(req)
if err != nil {
adapter.Healthy = false
adapter.LastError = err.Error()
adapter.LastCheckedAt = time.Now()
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
adapter.Healthy = true
adapter.LastError = ""
adapter.LastCheckedAt = time.Now()
} else {
adapter.Healthy = false
adapter.LastError = fmt.Sprintf("status code: %d", resp.StatusCode)
adapter.LastCheckedAt = time.Now()
}
r.adapters[id] = adapter
}
return nil
}
+281
View File
@@ -0,0 +1,281 @@
package importer
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestAdapterRegistry_Register(t *testing.T) {
registry := NewAdapterRegistry()
meta := AdapterMetadata{
ID: "test-adapter-v1",
Name: "Test Adapter",
Version: "1.0",
BmrtVersions: []string{"1.0"},
SupportedExtensions: []string{".json"},
BaseURL: "http://localhost:8181",
Capabilities: []string{"import", "export", "detect"},
Healthy: true,
}
err := registry.Register(meta)
if err != nil {
t.Fatalf("Failed to register adapter: %v", err)
}
// Verify adapter is registered
adapter := registry.Get("test-adapter-v1")
if adapter == nil {
t.Fatal("Adapter should be registered")
}
if adapter.ID != "test-adapter-v1" {
t.Errorf("Adapter ID = %v, want %v", adapter.ID, "test-adapter-v1")
}
if adapter.Name != "Test Adapter" {
t.Errorf("Adapter Name = %v, want %v", adapter.Name, "Test Adapter")
}
}
func TestAdapterRegistry_RegisterDuplicate(t *testing.T) {
registry := NewAdapterRegistry()
meta := AdapterMetadata{
ID: "test-adapter-v1",
Name: "Test Adapter",
Version: "1.0",
BmrtVersions: []string{"1.0"},
BaseURL: "http://localhost:8181",
Healthy: true,
}
err := registry.Register(meta)
if err != nil {
t.Fatalf("Failed to register adapter first time: %v", err)
}
// Try to register again - should update, not error
meta.Version = "1.1"
err = registry.Register(meta)
if err != nil {
t.Fatalf("Failed to update adapter: %v", err)
}
// Verify it was updated
adapter := registry.Get("test-adapter-v1")
if adapter.Version != "1.1" {
t.Errorf("Adapter version = %v, want %v", adapter.Version, "1.1")
}
}
func TestAdapterRegistry_GetHealthy(t *testing.T) {
registry := NewAdapterRegistry()
// Register healthy adapter
registry.Register(AdapterMetadata{
ID: "healthy-adapter",
Name: "Healthy",
BmrtVersions: []string{"1.0"},
BaseURL: "http://localhost:8181",
Healthy: true,
})
// Register unhealthy adapter
registry.Register(AdapterMetadata{
ID: "unhealthy-adapter",
Name: "Unhealthy",
BmrtVersions: []string{"1.0"},
BaseURL: "http://localhost:8182",
Healthy: false,
})
healthy := registry.GetHealthy()
if len(healthy) != 1 {
t.Errorf("Expected 1 healthy adapter, got %d", len(healthy))
}
if len(healthy) > 0 && healthy[0].ID != "healthy-adapter" {
t.Errorf("Healthy adapter ID = %v, want %v", healthy[0].ID, "healthy-adapter")
}
}
func TestAdapterRegistry_GetAll(t *testing.T) {
registry := NewAdapterRegistry()
registry.Register(AdapterMetadata{
ID: "adapter-1",
Name: "Adapter 1",
BmrtVersions: []string{"1.0"},
BaseURL: "http://localhost:8181",
Healthy: true,
})
registry.Register(AdapterMetadata{
ID: "adapter-2",
Name: "Adapter 2",
BmrtVersions: []string{"1.0"},
BaseURL: "http://localhost:8182",
Healthy: true,
})
all := registry.GetAll()
if len(all) != 2 {
t.Errorf("Expected 2 adapters, got %d", len(all))
}
}
func TestAdapterRegistry_Import(t *testing.T) {
// Create a mock adapter server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/import" {
t.Errorf("Expected path /import, got %s", r.URL.Path)
}
if r.Method != "POST" {
t.Errorf("Expected POST method, got %s", r.Method)
}
// Return a valid BMRT character
resp := BMRTCharacter{
BmrtVersion: "1.0",
Metadata: SourceMetadata{
SourceFormat: "test-format",
AdapterID: "test-adapter-v1",
ImportedAt: time.Now(),
},
}
resp.Name = "Test Character"
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
registry := NewAdapterRegistry()
registry.Register(AdapterMetadata{
ID: "test-adapter-v1",
Name: "Test Adapter",
BmrtVersions: []string{"1.0"},
BaseURL: server.URL,
Capabilities: []string{"import"},
Healthy: true,
})
data := []byte(`{"name": "Test Character"}`)
char, err := registry.Import("test-adapter-v1", data)
if err != nil {
t.Fatalf("Import failed: %v", err)
}
if char.Name != "Test Character" {
t.Errorf("Character name = %v, want %v", char.Name, "Test Character")
}
}
func TestAdapterRegistry_ImportNotFound(t *testing.T) {
registry := NewAdapterRegistry()
data := []byte(`{"name": "Test"}`)
_, err := registry.Import("non-existent-adapter", data)
if err == nil {
t.Error("Expected error for non-existent adapter")
}
}
func TestAdapterRegistry_ImportUnhealthy(t *testing.T) {
registry := NewAdapterRegistry()
registry.Register(AdapterMetadata{
ID: "unhealthy-adapter",
Name: "Unhealthy",
BaseURL: "http://localhost:8181",
Healthy: false,
})
data := []byte(`{"name": "Test"}`)
_, err := registry.Import("unhealthy-adapter", data)
if err == nil {
t.Error("Expected error for unhealthy adapter")
}
}
func TestAdapterRegistry_Detect(t *testing.T) {
// Create a mock adapter server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/detect" {
t.Errorf("Expected path /detect, got %s", r.URL.Path)
}
resp := map[string]interface{}{
"confidence": 0.95,
"version": "1.0",
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
registry := NewAdapterRegistry()
registry.Register(AdapterMetadata{
ID: "test-adapter-v1",
Name: "Test Adapter",
BmrtVersions: []string{"1.0"},
BaseURL: server.URL,
Capabilities: []string{"detect"},
Healthy: true,
})
data := []byte(`{"test": "data"}`)
adapterID, confidence, err := registry.Detect(data, "test.json")
if err != nil {
t.Fatalf("Detect failed: %v", err)
}
if adapterID != "test-adapter-v1" {
t.Errorf("Adapter ID = %v, want %v", adapterID, "test-adapter-v1")
}
if confidence < 0.9 {
t.Errorf("Confidence = %v, want >= 0.9", confidence)
}
}
func TestAdapterRegistry_DetectNoMatch(t *testing.T) {
// Create a mock adapter server that returns low confidence
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"confidence": 0.2,
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
registry := NewAdapterRegistry()
registry.Register(AdapterMetadata{
ID: "test-adapter-v1",
Name: "Test Adapter",
BmrtVersions: []string{"1.0"},
BaseURL: server.URL,
Capabilities: []string{"detect"},
Healthy: true,
})
data := []byte(`{"test": "data"}`)
_, _, err := registry.Detect(data, "test.json")
if err == nil {
t.Error("Expected error for low confidence detection")
}
}
func TestAdapterMetadata_SupportsCapability(t *testing.T) {
meta := AdapterMetadata{
Capabilities: []string{"import", "export"},
}
if !meta.SupportsCapability("import") {
t.Error("Should support import capability")
}
if !meta.SupportsCapability("export") {
t.Error("Should support export capability")
}
if meta.SupportsCapability("detect") {
t.Error("Should not support detect capability")
}
}
+193
View File
@@ -0,0 +1,193 @@
package importer
// ValidationResult represents the outcome of validating a character
type ValidationResult struct {
Valid bool `json:"valid"`
Errors []ValidationError `json:"errors,omitempty"`
Warnings []ValidationWarning `json:"warnings,omitempty"`
Source string `json:"source"` // "bmrt", "gamesystem", "adapter"
}
// ValidationError represents a validation error that prevents import
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
Source string `json:"source"` // Which validation phase found this
}
// ValidationWarning represents a non-blocking validation issue
type ValidationWarning struct {
Field string `json:"field"`
Message string `json:"message"`
Source string `json:"source"`
}
// ValidationRule defines the interface for validation rules
type ValidationRule interface {
Validate(char *BMRTCharacter) ValidationResult
}
// Validator manages validation rules and performs character validation
type Validator struct {
rules []ValidationRule
}
// NewValidator creates a new validator with default rules
func NewValidator() *Validator {
return &Validator{
rules: make([]ValidationRule, 0),
}
}
// AddRule adds a validation rule
func (v *Validator) AddRule(rule ValidationRule) {
v.rules = append(v.rules, rule)
}
// ValidateCharacter runs all validation rules and combines results
func (v *Validator) ValidateCharacter(char *BMRTCharacter) ValidationResult {
combined := ValidationResult{
Valid: true,
Errors: make([]ValidationError, 0),
Warnings: make([]ValidationWarning, 0),
}
for _, rule := range v.rules {
result := rule.Validate(char)
combined = CombineValidationResults(combined, result)
}
return combined
}
// CombineValidationResults combines multiple validation results
func CombineValidationResults(results ...ValidationResult) ValidationResult {
combined := ValidationResult{
Valid: true,
Errors: make([]ValidationError, 0),
Warnings: make([]ValidationWarning, 0),
}
for _, result := range results {
if !result.Valid {
combined.Valid = false
}
combined.Errors = append(combined.Errors, result.Errors...)
combined.Warnings = append(combined.Warnings, result.Warnings...)
}
return combined
}
// ========================================
// Phase 1: BMRT Structural Validation
// ========================================
// RequiredFieldsRule validates that required fields are present
type RequiredFieldsRule struct{}
func (r *RequiredFieldsRule) Validate(char *BMRTCharacter) ValidationResult {
result := ValidationResult{
Valid: true,
Source: "bmrt",
}
if char.Name == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "name",
Message: "Character name is required",
Source: "bmrt",
})
}
return result
}
// BmrtVersionRule validates the BMRT version is supported
type BmrtVersionRule struct{}
func (r *BmrtVersionRule) Validate(char *BMRTCharacter) ValidationResult {
result := ValidationResult{
Valid: true,
Source: "bmrt",
}
if !ValidateBMRTVersion(char.BmrtVersion) {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "bmrt_version",
Message: "Unsupported BMRT version: " + char.BmrtVersion,
Source: "bmrt",
})
}
return result
}
// ========================================
// Phase 2: Game System Semantic Validation
// ========================================
// StatsRangeRule validates game system stats are within valid ranges
type StatsRangeRule struct{}
func (r *StatsRangeRule) Validate(char *BMRTCharacter) ValidationResult {
result := ValidationResult{
Valid: true,
Source: "gamesystem",
}
// For Midgard, stats should be 0-100 (though can exceed in rare cases)
stats := map[string]int{
"St": char.Eigenschaften.St,
"Gw": char.Eigenschaften.Gw,
"Ko": char.Eigenschaften.Ko,
"In": char.Eigenschaften.In,
"Zt": char.Eigenschaften.Zt,
"Au": char.Eigenschaften.Au,
"Pa": char.Eigenschaften.Pa,
"Wk": char.Eigenschaften.Wk,
"Gs": char.Eigenschaften.Gs,
}
for name, value := range stats {
if value < 0 {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "eigenschaften." + name,
Message: "Stat cannot be negative",
Source: "gamesystem",
})
} else if value > 100 {
// Warning only - some characters can have stats > 100
result.Warnings = append(result.Warnings, ValidationWarning{
Field: "eigenschaften." + name,
Message: "Stat value unusually high (> 100)",
Source: "gamesystem",
})
}
}
return result
}
// ReferentialIntegrityRule validates that referenced items exist
// This is a placeholder - full implementation would check against game system master data
type ReferentialIntegrityRule struct{}
func (r *ReferentialIntegrityRule) Validate(char *BMRTCharacter) ValidationResult {
result := ValidationResult{
Valid: true,
Source: "gamesystem",
}
// Placeholder validation
// In full implementation, would check:
// - Skills reference valid skill categories
// - Spells exist in game system
// - Equipment types are valid
// etc.
return result
}
+213
View File
@@ -0,0 +1,213 @@
package importer
import (
"testing"
)
func TestValidation_RequiredFieldsRule(t *testing.T) {
rule := &RequiredFieldsRule{}
// Test valid character
char := CharacterImport{
Name: "Test Character",
}
bmrt := NewBMRTCharacter(char, "test-adapter", "test-format")
result := rule.Validate(bmrt)
if !result.Valid {
t.Errorf("Character with name should be valid, got errors: %v", result.Errors)
}
// Test invalid character (no name)
charInvalid := CharacterImport{}
bmrtInvalid := NewBMRTCharacter(charInvalid, "test-adapter", "test-format")
result = rule.Validate(bmrtInvalid)
if result.Valid {
t.Error("Character without name should be invalid")
}
if len(result.Errors) == 0 {
t.Error("Should have validation errors")
}
}
func TestValidation_BmrtVersionRule(t *testing.T) {
rule := &BmrtVersionRule{}
// Test valid version
char := CharacterImport{Name: "Test"}
bmrt := NewBMRTCharacter(char, "test-adapter", "test-format")
result := rule.Validate(bmrt)
if !result.Valid {
t.Errorf("Valid BMRT version should pass, got errors: %v", result.Errors)
}
// Test invalid version
bmrt.BmrtVersion = "99.9"
result = rule.Validate(bmrt)
if result.Valid {
t.Error("Invalid BMRT version should fail validation")
}
}
func TestValidation_StatsRangeRule(t *testing.T) {
rule := &StatsRangeRule{}
// Test valid stats
char := CharacterImport{
Name: "Test",
Eigenschaften: Eigenschaften{
St: 50,
Gw: 60,
Ko: 70,
In: 80,
Zt: 90,
},
}
bmrt := NewBMRTCharacter(char, "test-adapter", "test-format")
result := rule.Validate(bmrt)
if !result.Valid {
t.Errorf("Valid stats should pass, got errors: %v", result.Errors)
}
// Test invalid stats (out of range)
charInvalid := CharacterImport{
Name: "Test",
Eigenschaften: Eigenschaften{
St: 150, // Out of range - should only warn, not fail
Gw: 60,
},
}
bmrtInvalid := NewBMRTCharacter(charInvalid, "test-adapter", "test-format")
result = rule.Validate(bmrtInvalid)
// Stats > 100 are warnings, not errors
if !result.Valid {
t.Error("High stats should be valid (warning only)")
}
if len(result.Warnings) == 0 {
t.Error("High stats should generate warnings")
}
// Test negative stats (should fail)
charNegative := CharacterImport{
Name: "Test",
Eigenschaften: Eigenschaften{
St: -10, // Negative - should fail
},
}
bmrtNegative := NewBMRTCharacter(charNegative, "test-adapter", "test-format")
result = rule.Validate(bmrtNegative)
if result.Valid {
t.Error("Negative stats should fail validation")
}
}
func TestValidator_ValidateCharacter(t *testing.T) {
validator := NewValidator()
// Add rules
validator.AddRule(&RequiredFieldsRule{})
validator.AddRule(&BmrtVersionRule{})
// Test valid character
char := CharacterImport{
Name: "Test Character",
}
bmrt := NewBMRTCharacter(char, "test-adapter", "test-format")
result := validator.ValidateCharacter(bmrt)
if !result.Valid {
t.Errorf("Valid character should pass validation, errors: %v", result.Errors)
}
// Test invalid character
charInvalid := CharacterImport{} // Missing name
bmrtInvalid := NewBMRTCharacter(charInvalid, "test-adapter", "test-format")
bmrtInvalid.BmrtVersion = "99.9" // Invalid version
result = validator.ValidateCharacter(bmrtInvalid)
if result.Valid {
t.Error("Invalid character should fail validation")
}
if len(result.Errors) < 2 {
t.Errorf("Expected at least 2 errors, got %d", len(result.Errors))
}
}
func TestValidator_Warnings(t *testing.T) {
validator := NewValidator()
validator.AddRule(&testWarningRule{})
char := CharacterImport{Name: "Test"}
bmrt := NewBMRTCharacter(char, "test-adapter", "test-format")
result := validator.ValidateCharacter(bmrt)
if !result.Valid {
t.Error("Character should still be valid with warnings")
}
if len(result.Warnings) == 0 {
t.Error("Should have warnings")
}
}
// testWarningRule is a helper rule for testing warnings
type testWarningRule struct{}
func (r *testWarningRule) Validate(char *BMRTCharacter) ValidationResult {
return ValidationResult{
Valid: true,
Warnings: []ValidationWarning{
{
Field: "test_field",
Message: "This is a warning",
Source: "test",
},
},
}
}
func TestValidationResult_Combine(t *testing.T) {
result1 := ValidationResult{
Valid: true,
Errors: []ValidationError{
{Field: "field1", Message: "error1", Source: "source1"},
},
}
result2 := ValidationResult{
Valid: true,
Warnings: []ValidationWarning{
{Field: "field2", Message: "warning1", Source: "source2"},
},
}
combined := CombineValidationResults(result1, result2)
if len(combined.Errors) != 1 {
t.Errorf("Expected 1 error, got %d", len(combined.Errors))
}
if len(combined.Warnings) != 1 {
t.Errorf("Expected 1 warning, got %d", len(combined.Warnings))
}
if !combined.Valid {
t.Error("Combined result should be valid when both are valid")
}
// Test with one invalid
result3 := ValidationResult{
Valid: false,
Errors: []ValidationError{
{Field: "field3", Message: "error3", Source: "source3"},
},
}
combined = CombineValidationResults(result1, result3)
if combined.Valid {
t.Error("Combined result should be invalid when one is invalid")
}
}
+2 -4
View File
@@ -38,10 +38,8 @@ func MigrateStructure(db ...*gorm.DB) error {
if err != nil {
return err
}
err = importerMigrateStructure(targetDB)
if err != nil {
return err
}
// NOTE: importer package migrations are handled separately via importer.MigrateStructure()
// This is called from cmd/main.go to avoid import cycles
err = learningMigrateStructure(targetDB)
if err != nil {
return err
@@ -0,0 +1,60 @@
package models
import (
"bamort/database"
"testing"
"time"
)
// TestCharProvenanceFields verifies that the provenance fields (ImportedFromAdapter and ImportedAt) are correctly defined in the Char struct and can be set and retrieved without issues.
// This test ensures that the character model can track the source of imported characters, which is crucial for data provenance and debugging import issues.
// logical it belongs to the character model tests since it is testing fields on the Char struct, but it is specifically focused on the provenance tracking fields that were recently added to support the import system. This test ensures that these new fields are properly integrated and functional within the character model.
func TestCharProvenanceFields(t *testing.T) {
setupTestDB(t)
db := database.DB
// Run migrations
err := characterMigrateStructure(db)
if err != nil {
t.Fatalf("Failed to migrate character structure: %v", err)
}
// Check that provenance fields exist
// This is verified by the migration not failing and the struct compiling
// Create a test character with provenance
adapterID := "foundry-vtt-v1"
importedAt := time.Now()
char := Char{
BamortBase: BamortBase{
Name: "Test Import Character",
},
GameSystem: "midgard",
UserID: uint(1),
Typ: "Krieger",
ImportedFromAdapter: &adapterID,
ImportedAt: &importedAt,
}
// Create the character
result := db.Create(&char)
if result.Error != nil {
t.Fatalf("Failed to create character with provenance: %v", result.Error)
}
// Fetch it back
var fetched Char
result = db.First(&fetched, char.ID)
if result.Error != nil {
t.Fatalf("Failed to fetch character: %v", result.Error)
}
// Verify provenance fields
if fetched.ImportedFromAdapter == nil || *fetched.ImportedFromAdapter != adapterID {
t.Errorf("ImportedFromAdapter = %v, want %v", fetched.ImportedFromAdapter, adapterID)
}
if fetched.ImportedAt == nil {
t.Error("ImportedAt should not be nil")
}
}
+4
View File
@@ -4,6 +4,7 @@ import (
"bamort/database"
"bamort/user"
"fmt"
"time"
"gorm.io/gorm"
)
@@ -116,6 +117,9 @@ type Char struct {
Transportmittel []EqContainer `gorm:"foreignKey:CharacterID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"transportmittel"`
Ausruestung []EqAusruestung `gorm:"foreignKey:CharacterID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"ausruestung"`
Image string `json:"image,omitempty"`
// Import provenance tracking
ImportedFromAdapter *string `gorm:"type:varchar(100)" json:"imported_from_adapter,omitempty"` // e.g., "foundry-vtt-v1"
ImportedAt *time.Time `json:"imported_at,omitempty"`
}
type CharList struct {
BamortBase
+7 -15
View File
@@ -1,21 +1,13 @@
package models
import "gorm.io/gorm"
import (
"gorm.io/gorm"
)
// importerMigrateStructure is a placeholder - actual importer migrations
// are now handled by the importer package itself via importer.MigrateStructure()
// which is called from cmd/main.go after models.MigrateStructure()
func importerMigrateStructure(db ...*gorm.DB) error {
// Use provided DB or default to database.DB
// var targetDB *gorm.DB
// if len(db) > 0 && db[0] != nil {
// targetDB = db[0]
// } else {
// targetDB = database.DB
// }
/*
err := targetDB.AutoMigrate()
if err != nil {
return err
}
*/
// No longer needed - migrations handled by importer package
return nil
}