package tools import ( "context" "testing" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) // helpers func newCodexWithDefaults(name, strategy string, extras []string) *providers.CodexProvider { p := providers.NewCodexProvider(name, nil, "", "") if strategy != "" || len(extras) > 0 { p = p.WithRoutingDefaults(strategy, extras) } return p } func mustTenantCtx() context.Context { return store.WithTenantID(context.Background(), uuid.New()) } func registryWith(providers_ ...*providers.CodexProvider) *providers.Registry { reg := providers.NewRegistry(nil) for _, p := range providers_ { reg.Register(p) } return reg } // TestWrapsWhenCodexHasExtras: Codex with round_robin strategy and extra members // → should wrap to *ChatGPTOAuthRouter. func TestWrapsWhenCodexHasExtras(t *testing.T) { base := newCodexWithDefaults("base", "round_robin", []string{"extra1", "extra2"}) extra1 := newCodexWithDefaults("extra1", "", nil) extra2 := newCodexWithDefaults("extra2", "", nil) reg := registryWith(base, extra1, extra2) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "base", base) if _, ok := got.(*providers.ChatGPTOAuthRouter); !ok { t.Errorf("wrapPoolProvider() = %T, want *providers.ChatGPTOAuthRouter", got) } } // TestWrapsWhenPriorityOrderWithMembers: Codex with priority_order + extras → wraps. func TestWrapsWhenPriorityOrderWithMembers(t *testing.T) { base := newCodexWithDefaults("base", "priority_order", []string{"extra1"}) extra1 := newCodexWithDefaults("extra1", "", nil) reg := registryWith(base, extra1) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "base", base) if _, ok := got.(*providers.ChatGPTOAuthRouter); !ok { t.Errorf("wrapPoolProvider() = %T, want *providers.ChatGPTOAuthRouter", got) } } // TestDoesNotWrapSoloCodexNilDefaults: Codex with nil RoutingDefaults → no wrap. func TestDoesNotWrapSoloCodexNilDefaults(t *testing.T) { // Not calling WithRoutingDefaults → RoutingDefaults() returns nil. base := providers.NewCodexProvider("base", nil, "", "") reg := registryWith(base) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "base", base) if got != base { t.Errorf("wrapPoolProvider() returned %T, want original *CodexProvider (no wrap)", got) } } // TestDoesNotWrapPrimaryFirstNoExtras: strategy primary_first (not round_robin/priority_order), // extras empty → returns provider unchanged. func TestDoesNotWrapPrimaryFirstNoExtras(t *testing.T) { base := newCodexWithDefaults("base", "primary_first", []string{}) reg := registryWith(base) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "base", base) if got != base { t.Errorf("wrapPoolProvider() with primary_first + no extras: want original provider, got %T", got) } } // TestDoesNotWrapNonCodex: non-Codex provider (byteplus style) → unchanged. func TestDoesNotWrapNonCodex(t *testing.T) { reg := providers.NewRegistry(nil) fake := &fakeNonCodexProvider{name: "byteplus"} reg.Register(fake) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "byteplus", fake) if got != fake { t.Errorf("wrapPoolProvider() returned %T, want original non-Codex provider unchanged", got) } } // TestFallsBackWhenRouterHasNoRegisteredMembers: extras reference missing providers // → router has no registered members → return original Codex. func TestFallsBackWhenRouterHasNoRegisteredMembers(t *testing.T) { // Only base registered; extra1 and extra2 are NOT in registry. base := newCodexWithDefaults("base", "round_robin", []string{"missing1", "missing2"}) reg := registryWith(base) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "base", base) // Router should fall back because HasRegisteredProviders() returns false // (only base is registered as member, extras missing — router counts base + extras // but can't resolve extras → members list = [base alone], which IS > 0). // Per spec: if router.HasRegisteredProviders() == false → return original. // With base registered and extras missing, registeredProviders() returns [base], // so HasRegisteredProviders() == true → we get a router. Adjust test to check // that wrap happens only when extras are actually resolvable (spec says ≥1 extra): // Since base alone resolves but extra members don't, router.HasRegisteredProviders() // is true (base is a member). The phase spec says "Wrapped router's // HasRegisteredProviders() false → return resolved (don't inject broken router)." // In this case it's NOT false (base resolves as self-member). Router is returned. // This test verifies the fallback only when zero members resolve. if _, ok := got.(*providers.ChatGPTOAuthRouter); !ok { // When extras are missing but base itself is a Codex in the registry, // HasRegisteredProviders() is true (base counts as a member). // The router IS valid here, so a router is expected. t.Errorf("wrapPoolProvider() with missing extras but base present: got %T, want *ChatGPTOAuthRouter (base self-resolves)", got) } } // TestFallsBackWhenZeroMembersResolve: verifies we return original when the // router genuinely has NO registered members. func TestFallsBackWhenZeroMembersResolve(t *testing.T) { // base NOT registered in registry; extras also missing. // We pass the base provider directly to wrapPoolProvider but don't register // it, so GetForTenant won't find it as a Codex — however the router looks up // the default + extras from the registry, not from the passed provider. base := newCodexWithDefaults("ghost", "round_robin", []string{"missing1"}) reg := providers.NewRegistry(nil) // empty registry — nothing registered ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "ghost", base) // Router created but HasRegisteredProviders() == false → must return original. if got != base { t.Errorf("wrapPoolProvider() with empty registry: want original provider, got %T", got) } } // TestNoTenantInContext_ReturnsCodex: missing tenant in ctx → safe degrade → original provider. func TestNoTenantInContext_ReturnsCodex(t *testing.T) { base := newCodexWithDefaults("base", "round_robin", []string{"extra1"}) extra1 := newCodexWithDefaults("extra1", "", nil) reg := registryWith(base, extra1) // No tenant in context → TenantIDFromContext returns uuid.Nil. ctx := context.Background() got := wrapPoolProvider(ctx, reg, "base", base) if got != base { t.Errorf("wrapPoolProvider() without tenant ctx: want original provider (safe degrade), got %T", got) } } // TestWrappedRouterSatisfiesNativeImageProvider: wrapped result can be // type-asserted to NativeImageProvider. func TestWrappedRouterSatisfiesNativeImageProvider(t *testing.T) { base := newCodexWithDefaults("base", "round_robin", []string{"extra1"}) extra1 := newCodexWithDefaults("extra1", "", nil) reg := registryWith(base, extra1) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "base", base) if _, ok := got.(providers.NativeImageProvider); !ok { t.Errorf("wrapPoolProvider() result %T does not satisfy NativeImageProvider", got) } } // TestParamsInjection: _native_provider in ExecuteWithChain callParams is the // *ChatGPTOAuthRouter, not the bare *CodexProvider. func TestParamsInjection(t *testing.T) { base := newCodexWithDefaults("pool_base", "round_robin", []string{"extra1"}) extra1 := newCodexWithDefaults("extra1", "", nil) reg := registryWith(base, extra1) tenantID := uuid.New() reg.RegisterForTenant(tenantID, base) reg.RegisterForTenant(tenantID, extra1) ctx := store.WithTenantID(context.Background(), tenantID) chain := []MediaProviderEntry{{ Provider: "pool_base", Model: "gpt-image-1", Enabled: true, Timeout: 10, MaxRetries: 1, }} // capturedNative captures whatever _native_provider lands in callParams. var capturedNative any fn := func(fnCtx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error) { capturedNative = params["_native_provider"] return []byte("ok"), nil, nil } _, err := ExecuteWithChain(ctx, chain, reg, fn) if err != nil { t.Fatalf("ExecuteWithChain returned error: %v", err) } if _, ok := capturedNative.(*providers.ChatGPTOAuthRouter); !ok { t.Errorf("_native_provider = %T, want *providers.ChatGPTOAuthRouter", capturedNative) } } // TestStrategyPassedThrough: verifies round_robin strategy is preserved in the // router by checking the router is created (strategy is opaque; tested indirectly // by confirming the router forms from round_robin vs priority_order inputs). func TestStrategyPassedThrough(t *testing.T) { for _, strategy := range []string{"round_robin", "priority_order"} { t.Run(strategy, func(t *testing.T) { base := newCodexWithDefaults("base", strategy, []string{"extra1"}) extra1 := newCodexWithDefaults("extra1", "", nil) reg := registryWith(base, extra1) ctx := mustTenantCtx() got := wrapPoolProvider(ctx, reg, "base", base) if _, ok := got.(*providers.ChatGPTOAuthRouter); !ok { t.Errorf("strategy %q: wrapPoolProvider() = %T, want *ChatGPTOAuthRouter", strategy, got) } }) } } // fakeNonCodexProvider is a minimal non-Codex provider for testing. // Implements only the providers.Provider interface — no Codex-specific methods. type fakeNonCodexProvider struct { name string } func (f *fakeNonCodexProvider) Name() string { return f.name } func (f *fakeNonCodexProvider) DefaultModel() string { return "" } func (f *fakeNonCodexProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) { return nil, nil } func (f *fakeNonCodexProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) { return nil, nil }