vibe-proxy/backend/internal/watcher/synthesizer/file_test.go
2026-08-24 00:10:41 +02:00

832 lines
24 KiB
Go

package synthesizer
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestNewFileSynthesizer(t *testing.T) {
synth := NewFileSynthesizer()
if synth == nil {
t.Fatal("expected non-nil synthesizer")
}
}
func TestFileSynthesizer_Synthesize_NilContext(t *testing.T) {
synth := NewFileSynthesizer()
auths, err := synth.Synthesize(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 0 {
t.Fatalf("expected empty auths, got %d", len(auths))
}
}
func TestFileSynthesizer_Synthesize_EmptyAuthDir(t *testing.T) {
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: "",
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 0 {
t.Fatalf("expected empty auths, got %d", len(auths))
}
}
func TestFileSynthesizer_Synthesize_NonExistentDir(t *testing.T) {
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: "/non/existent/path",
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 0 {
t.Fatalf("expected empty auths, got %d", len(auths))
}
}
func TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) {
tempDir := t.TempDir()
// Create a valid auth file
authData := map[string]any{
"type": "claude",
"email": "test@example.com",
"proxy_url": "http://proxy.local",
"prefix": "test-prefix",
"headers": map[string]string{
" X-Test ": " value ",
"X-Empty": " ",
},
"disable_cooling": true,
"request_retry": 2,
}
data, _ := json.Marshal(authData)
err := os.WriteFile(filepath.Join(tempDir, "claude-auth.json"), data, 0644)
if err != nil {
t.Fatalf("failed to write auth file: %v", err)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
if auths[0].Provider != "claude" {
t.Errorf("expected provider claude, got %s", auths[0].Provider)
}
if auths[0].Label != "test@example.com" {
t.Errorf("expected label test@example.com, got %s", auths[0].Label)
}
if auths[0].Prefix != "test-prefix" {
t.Errorf("expected prefix test-prefix, got %s", auths[0].Prefix)
}
if auths[0].ProxyURL != "http://proxy.local" {
t.Errorf("expected proxy_url http://proxy.local, got %s", auths[0].ProxyURL)
}
if got := auths[0].Attributes["header:X-Test"]; got != "value" {
t.Errorf("expected header:X-Test value, got %q", got)
}
if _, ok := auths[0].Attributes["header:X-Empty"]; ok {
t.Errorf("expected header:X-Empty to be absent, got %q", auths[0].Attributes["header:X-Empty"])
}
if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v {
t.Errorf("expected disable_cooling true, got %v", auths[0].Metadata["disable_cooling"])
}
if v, ok := auths[0].Metadata["request_retry"].(float64); !ok || int(v) != 2 {
t.Errorf("expected request_retry 2, got %v", auths[0].Metadata["request_retry"])
}
if auths[0].Status != coreauth.StatusActive {
t.Errorf("expected status active, got %s", auths[0].Status)
}
}
func TestFileSynthesizer_Synthesize_LegacyKimiFingerprintProfile(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "kimi",
"access_token": "kimi-access-token",
"refresh_token": "kimi-refresh-token",
"fingerprint-profile": "claude-code-cli",
}
data, errMarshal := json.Marshal(authData)
if errMarshal != nil {
t.Fatalf("marshal kimi auth: %v", errMarshal)
}
if err := os.WriteFile(filepath.Join(tempDir, "kimi-auth.json"), data, 0644); err != nil {
t.Fatalf("failed to write kimi auth file: %v", err)
}
auths, err := NewFileSynthesizer().Synthesize(&SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
IDGenerator: NewStableIDGenerator(),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
if auths[0].Provider != "kimi" {
t.Fatalf("provider = %q, want kimi", auths[0].Provider)
}
if got := auths[0].Attributes["fingerprint_profile"]; got != "claude-code-cli" {
t.Fatalf("attributes fingerprint_profile = %q, want claude-code-cli", got)
}
if got, _ := auths[0].Metadata["fingerprint_profile"].(string); got != "claude-code-cli" {
t.Fatalf("metadata fingerprint_profile = %q, want claude-code-cli", got)
}
if _, exists := auths[0].Metadata["fingerprint-profile"]; exists {
t.Fatalf("legacy fingerprint-profile was not normalized: %#v", auths[0].Metadata)
}
}
func TestFileSynthesizer_Synthesize_IgnoresGeminiProviderFile(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "gemini",
"email": "gemini@example.com",
}
data, _ := json.Marshal(authData)
err := os.WriteFile(filepath.Join(tempDir, "gemini-auth.json"), data, 0644)
if err != nil {
t.Fatalf("failed to write auth file: %v", err)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 0 {
t.Fatalf("expected Gemini auth file to be ignored, got %d auths", len(auths))
}
}
func TestSynthesizeAuthFileExpandsPluginMultiAuths(t *testing.T) {
tempDir := t.TempDir()
fullPath := filepath.Join(tempDir, "geminicli.json")
raw := []byte(`{"type":"gemini-cli","excluded_models":["model-a"],"headers":{"X-Test":"value"}}`)
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
PluginAuthParser: multiAuthParserFunc(func(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
if req.Provider != "gemini-cli" || req.Path != fullPath || req.FileName != "geminicli.json" {
t.Fatalf("ParseAuths request = %#v, want file context", req)
}
return []*coreauth.Auth{
{
ID: "geminicli.json",
Provider: "gemini-cli",
Metadata: map[string]any{
"type": "gemini-cli",
"headers": map[string]any{
"X-Test": "value",
},
},
},
nil,
{
ID: "geminicli-project-a.json",
Provider: "gemini-cli",
Metadata: map[string]any{
"type": "gemini-cli",
"project_id": "project-a",
"headers": map[string]any{
"X-Test": "value",
},
},
},
}, true, nil
}),
}
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, raw)
if errSynthesize != nil {
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
}
if len(auths) != 2 {
t.Fatalf("SynthesizeAuthFile() len = %d, want two plugin auths", len(auths))
}
if firstIndex, secondIndex := auths[0].EnsureIndex(), auths[1].EnsureIndex(); firstIndex == "" || firstIndex == secondIndex {
t.Fatalf("auth indexes = %q/%q, want distinct non-empty indexes", firstIndex, secondIndex)
}
for _, auth := range auths {
if !coreauth.IsPluginVirtualAuth(auth) {
t.Fatalf("auth attributes = %#v, want plugin virtual marker", auth.Attributes)
}
if auth.Attributes[coreauth.AttributeVirtualSource] != fullPath {
t.Fatalf("virtual_source = %q, want %q", auth.Attributes[coreauth.AttributeVirtualSource], fullPath)
}
if auth.Attributes["path"] != fullPath || auth.Attributes["source"] != fullPath {
t.Fatalf("auth attributes = %#v, want source path", auth.Attributes)
}
if gotHeader := auth.Attributes["header:X-Test"]; gotHeader != "value" {
t.Fatalf("header:X-Test = %q, want value", gotHeader)
}
if gotKind := auth.Attributes["auth_kind"]; gotKind != "oauth" {
t.Fatalf("auth_kind = %q, want oauth", gotKind)
}
}
if gotProject := auths[1].Metadata["project_id"]; gotProject != "project-a" {
t.Fatalf("project_id = %#v, want project-a", gotProject)
}
}
func TestSynthesizeAuthFileSkipsInvalidPluginAuthWeight(t *testing.T) {
tempDir := t.TempDir()
fullPath := filepath.Join(tempDir, "plugin.json")
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
return []*coreauth.Auth{
{ID: "invalid", Provider: "plugin", Attributes: map[string]string{coreauth.AttributeWeight: "1.5"}},
{ID: "valid", Provider: "plugin", Attributes: map[string]string{coreauth.AttributeWeight: "0"}},
}, true, nil
}),
}
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, []byte(`{"type":"plugin"}`))
if errSynthesize != nil {
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
}
if len(auths) != 1 || auths[0].ID != "valid" {
t.Fatalf("SynthesizeAuthFile() auths = %#v, want only valid zero-weight auth", auths)
}
}
func TestSynthesizeAuthFileAppliesSourceDisabledToPluginMultiAuths(t *testing.T) {
tempDir := t.TempDir()
fullPath := filepath.Join(tempDir, "geminicli.json")
raw := []byte(`{"type":"gemini-cli","disabled":true}`)
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
return []*coreauth.Auth{
{ID: "geminicli.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli"}},
{ID: "geminicli-project-a.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"}},
}, true, nil
}),
}
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, raw)
if errSynthesize != nil {
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
}
if len(auths) != 2 {
t.Fatalf("SynthesizeAuthFile() len = %d, want two plugin auths", len(auths))
}
for _, auth := range auths {
if !auth.Disabled || auth.Status != coreauth.StatusDisabled {
t.Fatalf("auth %s disabled/status = %v/%s, want disabled", auth.ID, auth.Disabled, auth.Status)
}
if got, _ := auth.Metadata["disabled"].(bool); !got {
t.Fatalf("auth %s metadata disabled = %#v, want true", auth.ID, auth.Metadata["disabled"])
}
}
}
func TestSynthesizeAuthFilePluginHandledEmptySuppressesBuiltin(t *testing.T) {
tempDir := t.TempDir()
fullPath := filepath.Join(tempDir, "codex.json")
raw := []byte(`{"type":"codex","access_token":"token"}`)
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC),
PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
return nil, true, nil
}),
}
auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, raw)
if errSynthesize != nil {
t.Fatalf("SynthesizeAuthFile() error = %v", errSynthesize)
}
if len(auths) != 0 {
t.Fatalf("SynthesizeAuthFile() len = %d, want plugin-handled empty result", len(auths))
}
}
type multiAuthParserFunc func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error)
func (f multiAuthParserFunc) ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
return nil, false, nil
}
func (f multiAuthParserFunc) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
return f(ctx, req)
}
func TestFileSynthesizer_Synthesize_SkipsInvalidFiles(t *testing.T) {
tempDir := t.TempDir()
// Create various invalid files
_ = os.WriteFile(filepath.Join(tempDir, "not-json.txt"), []byte("text content"), 0644)
_ = os.WriteFile(filepath.Join(tempDir, "invalid.json"), []byte("not valid json"), 0644)
_ = os.WriteFile(filepath.Join(tempDir, "empty.json"), []byte(""), 0644)
_ = os.WriteFile(filepath.Join(tempDir, "no-type.json"), []byte(`{"email": "test@example.com"}`), 0644)
// Create one valid file
validData, _ := json.Marshal(map[string]any{"type": "claude", "email": "valid@example.com"})
_ = os.WriteFile(filepath.Join(tempDir, "valid.json"), validData, 0644)
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("only valid auth file should be processed, got %d", len(auths))
}
if auths[0].Label != "valid@example.com" {
t.Errorf("expected label valid@example.com, got %s", auths[0].Label)
}
}
func TestFileSynthesizer_Synthesize_SkipsDirectories(t *testing.T) {
tempDir := t.TempDir()
// Create a subdirectory with a json file inside
subDir := filepath.Join(tempDir, "subdir.json")
err := os.Mkdir(subDir, 0755)
if err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
// Create a valid file in root
validData, _ := json.Marshal(map[string]any{"type": "claude"})
_ = os.WriteFile(filepath.Join(tempDir, "valid.json"), validData, 0644)
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
}
func TestFileSynthesizer_Synthesize_RelativeID(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{"type": "claude"}
data, _ := json.Marshal(authData)
err := os.WriteFile(filepath.Join(tempDir, "my-auth.json"), data, 0644)
if err != nil {
t.Fatalf("failed to write auth file: %v", err)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
// ID should be relative path
if auths[0].ID != "my-auth.json" {
t.Errorf("expected ID my-auth.json, got %s", auths[0].ID)
}
}
func TestFileSynthesizer_Synthesize_PrefixValidation(t *testing.T) {
tests := []struct {
name string
prefix string
wantPrefix string
}{
{"valid prefix", "myprefix", "myprefix"},
{"prefix with slashes trimmed", "/myprefix/", "myprefix"},
{"prefix with spaces trimmed", " myprefix ", "myprefix"},
{"prefix with internal slash rejected", "my/prefix", ""},
{"empty prefix", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "claude",
"prefix": tt.prefix,
}
data, _ := json.Marshal(authData)
_ = os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
if auths[0].Prefix != tt.wantPrefix {
t.Errorf("expected prefix %q, got %q", tt.wantPrefix, auths[0].Prefix)
}
})
}
}
func TestFileSynthesizer_Synthesize_PriorityParsing(t *testing.T) {
tests := []struct {
name string
priority any
want string
hasValue bool
}{
{
name: "string with spaces",
priority: " 10 ",
want: "10",
hasValue: true,
},
{
name: "number",
priority: 8,
want: "8",
hasValue: true,
},
{
name: "invalid string",
priority: "1x",
hasValue: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "claude",
"priority": tt.priority,
}
data, _ := json.Marshal(authData)
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
if errWriteFile != nil {
t.Fatalf("failed to write auth file: %v", errWriteFile)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, errSynthesize := synth.Synthesize(ctx)
if errSynthesize != nil {
t.Fatalf("unexpected error: %v", errSynthesize)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
value, ok := auths[0].Attributes["priority"]
if tt.hasValue {
if !ok {
t.Fatal("expected priority attribute to be set")
}
if value != tt.want {
t.Fatalf("expected priority %q, got %q", tt.want, value)
}
return
}
if ok {
t.Fatalf("expected priority attribute to be absent, got %q", value)
}
})
}
}
func TestFileSynthesizer_Synthesize_WeightParsing(t *testing.T) {
tests := []struct {
name string
weight any
want string
valid bool
}{
{name: "number", weight: 5, want: "5", valid: true},
{name: "numeric string", weight: " 3 ", want: "3", valid: true},
{name: "zero excludes", weight: 0, want: "0", valid: true},
{name: "negative excludes", weight: -5, want: "0", valid: true},
{name: "maximum", weight: 1000000, want: "1000000", valid: true},
{name: "fraction rejected", weight: 1.5},
{name: "above maximum rejected", weight: 1000001},
{name: "overflow rejected", weight: "9223372036854775808"},
{name: "invalid string", weight: "heavy"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
tempDir := t.TempDir()
data, errMarshal := json.Marshal(map[string]any{"type": "claude", "weight": testCase.weight})
if errMarshal != nil {
t.Fatalf("json.Marshal() error = %v", errMarshal)
}
if errWrite := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, errSynthesize := NewFileSynthesizer().Synthesize(ctx)
if errSynthesize != nil {
t.Fatalf("Synthesize() error = %v", errSynthesize)
}
if !testCase.valid {
if len(auths) != 0 {
t.Fatalf("auth count = %d, want invalid credential skipped", len(auths))
}
if _, errDirect := SynthesizeAuthFile(ctx, filepath.Join(tempDir, "auth.json"), data); errDirect == nil {
t.Fatal("SynthesizeAuthFile() error = nil, want weight validation error")
}
return
}
if len(auths) != 1 {
t.Fatalf("auth count = %d, want 1", len(auths))
}
if gotWeight := auths[0].Attributes[coreauth.AttributeWeight]; gotWeight != testCase.want {
t.Fatalf("weight = %q, want %q", gotWeight, testCase.want)
}
})
}
}
func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "claude",
"excluded_models": []string{"custom-model", "MODEL-B"},
}
data, _ := json.Marshal(authData)
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
if errWriteFile != nil {
t.Fatalf("failed to write auth file: %v", errWriteFile)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{
OAuthExcludedModels: map[string][]string{
"claude": {"shared", "model-b"},
},
},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, errSynthesize := synth.Synthesize(ctx)
if errSynthesize != nil {
t.Fatalf("unexpected error: %v", errSynthesize)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
got := auths[0].Attributes["excluded_models"]
want := "custom-model,model-b,shared"
if got != want {
t.Fatalf("expected excluded_models %q, got %q", want, got)
}
}
func TestFileSynthesizer_Synthesize_OAuthModelAliases(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "codex",
"email": "codex@example.com",
"model_aliases": []map[string]any{
{"name": " gpt-5.3-codex-spark ", "alias": " gpt-5.5 "},
{"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4", "fork": true},
{"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"},
{"name": "", "alias": "ignored"},
},
}
data, _ := json.Marshal(authData)
errWriteFile := os.WriteFile(filepath.Join(tempDir, "codex-auth.json"), data, 0644)
if errWriteFile != nil {
t.Fatalf("failed to write auth file: %v", errWriteFile)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, errSynthesize := synth.Synthesize(ctx)
if errSynthesize != nil {
t.Fatalf("unexpected error: %v", errSynthesize)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
got := auths[0].Attributes["model_aliases"]
want := `[{"name":"gpt-5.3-codex-spark","alias":"gpt-5.5"},{"name":"gpt-5.3-codex-spark","alias":"gpt-5.4","fork":true}]`
if got != want {
t.Fatalf("expected model_aliases %q, got %q", want, got)
}
}
func TestFileSynthesizer_Synthesize_IgnoresGeminiOAuthFile(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "gemini",
"email": "multi@example.com",
"project_id": "project-a, project-b, project-c",
"priority": " 10 ",
}
data, _ := json.Marshal(authData)
err := os.WriteFile(filepath.Join(tempDir, "gemini-multi.json"), data, 0644)
if err != nil {
t.Fatalf("failed to write auth file: %v", err)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, err := synth.Synthesize(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 0 {
t.Fatalf("expected Gemini auth file to be ignored, got %d auths", len(auths))
}
}
func TestFileSynthesizer_Synthesize_NoteParsing(t *testing.T) {
tests := []struct {
name string
note any
want string
hasValue bool
}{
{
name: "valid string note",
note: "hello world",
want: "hello world",
hasValue: true,
},
{
name: "string note with whitespace",
note: " trimmed note ",
want: "trimmed note",
hasValue: true,
},
{
name: "empty string note",
note: "",
hasValue: false,
},
{
name: "whitespace only note",
note: " ",
hasValue: false,
},
{
name: "non-string note ignored",
note: 12345,
hasValue: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "claude",
"note": tt.note,
}
data, _ := json.Marshal(authData)
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
if errWriteFile != nil {
t.Fatalf("failed to write auth file: %v", errWriteFile)
}
synth := NewFileSynthesizer()
ctx := &SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
}
auths, errSynthesize := synth.Synthesize(ctx)
if errSynthesize != nil {
t.Fatalf("unexpected error: %v", errSynthesize)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
value, ok := auths[0].Attributes["note"]
if tt.hasValue {
if !ok {
t.Fatal("expected note attribute to be set")
}
if value != tt.want {
t.Fatalf("expected note %q, got %q", tt.want, value)
}
return
}
if ok {
t.Fatalf("expected note attribute to be absent, got %q", value)
}
})
}
}