Phase 1: Core Infrastructure
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user