diff --git a/internal/service/sync_bindings_spec_test.go b/internal/service/sync_bindings_spec_test.go new file mode 100644 index 0000000..bb6c181 --- /dev/null +++ b/internal/service/sync_bindings_spec_test.go @@ -0,0 +1,127 @@ +package service + +import ( + "encoding/json" + "strconv" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/ez-api/ez-api/internal/model" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type bindingSnapshot struct { + Namespace string `json:"namespace"` + PublicModel string `json:"public_model"` + RouteGroup string `json:"route_group"` + Upstreams map[string]string `json:"upstreams"` +} + +func TestSyncBindings_SelectorExact(t *testing.T) { + t.Parallel() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Provider{}, &model.Binding{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + p := model.Provider{Name: "p1", Type: "openai", Group: "rg", Models: "m"} + if err := db.Create(&p).Error; err != nil { + t.Fatalf("create provider: %v", err) + } + b := model.Binding{Namespace: "ns", PublicModel: "m", RouteGroup: "rg", SelectorType: "exact", Status: "active"} + if err := db.Create(&b).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + svc := NewSyncService(rdb) + if err := svc.SyncBindings(db); err != nil { + t.Fatalf("SyncBindings: %v", err) + } + + raw := mr.HGet("config:bindings", "ns.m") + if raw == "" { + t.Fatalf("expected config:bindings entry") + } + var snap bindingSnapshot + if err := json.Unmarshal([]byte(raw), &snap); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if snap.Upstreams == nil || snap.Upstreams[jsonID(p.ID)] != "m" { + t.Fatalf("unexpected upstreams: %+v", snap.Upstreams) + } +} + +func TestSyncBindings_SelectorRegexAndNormalize(t *testing.T) { + t.Parallel() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Provider{}, &model.Binding{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + p1 := model.Provider{Name: "p1", Type: "openai", Group: "rg", Models: "moonshot/kimi2,kimi2"} + p2 := model.Provider{Name: "p2", Type: "openai", Group: "rg", Models: "moonshot/kimi2"} + if err := db.Create(&p1).Error; err != nil { + t.Fatalf("create provider1: %v", err) + } + if err := db.Create(&p2).Error; err != nil { + t.Fatalf("create provider2: %v", err) + } + + // Regex should match uniquely (moonshot/kimi2 only). + bRegex := model.Binding{Namespace: "ns", PublicModel: "kimi2", RouteGroup: "rg", SelectorType: "regex", SelectorValue: "^moonshot/kimi2$", Status: "active"} + if err := db.Create(&bRegex).Error; err != nil { + t.Fatalf("create binding regex: %v", err) + } + + // Normalize_exact should match p2 (moonshot/kimi2) for "kimi2". + bNorm := model.Binding{Namespace: "ns", PublicModel: "kimi2-n", RouteGroup: "rg", SelectorType: "normalize_exact", SelectorValue: "kimi2", Status: "active"} + if err := db.Create(&bNorm).Error; err != nil { + t.Fatalf("create binding normalize: %v", err) + } + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + svc := NewSyncService(rdb) + if err := svc.SyncBindings(db); err != nil { + t.Fatalf("SyncBindings: %v", err) + } + + // Regex binding should include both providers mapped to moonshot/kimi2 (p1 also has it). + raw := mr.HGet("config:bindings", "ns.kimi2") + var snapRegex bindingSnapshot + if err := json.Unmarshal([]byte(raw), &snapRegex); err != nil { + t.Fatalf("unmarshal regex: %v", err) + } + if snapRegex.Upstreams[jsonID(p1.ID)] != "moonshot/kimi2" || snapRegex.Upstreams[jsonID(p2.ID)] != "moonshot/kimi2" { + t.Fatalf("unexpected regex upstreams: %+v", snapRegex.Upstreams) + } + + // Normalize_exact binding should include p2 but exclude p1 due to multi-match (moonshot/kimi2 + kimi2). + raw = mr.HGet("config:bindings", "ns.kimi2-n") + var snapNorm bindingSnapshot + if err := json.Unmarshal([]byte(raw), &snapNorm); err != nil { + t.Fatalf("unmarshal normalize: %v", err) + } + if snapNorm.Upstreams[jsonID(p2.ID)] != "moonshot/kimi2" { + t.Fatalf("expected p2 upstream, got %+v", snapNorm.Upstreams) + } + if _, ok := snapNorm.Upstreams[jsonID(p1.ID)]; ok { + t.Fatalf("did not expect p1 upstream due to normalize multi-match, got %+v", snapNorm.Upstreams) + } +} + +func jsonID(id uint) string { + return strconv.FormatUint(uint64(id), 10) +}