config_test.go

  1package config
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"testing"
  8
  9	"github.com/kujtimiihoxha/termai/internal/llm/models"
 10	"github.com/spf13/viper"
 11	"github.com/stretchr/testify/assert"
 12	"github.com/stretchr/testify/require"
 13)
 14
 15func TestLoad(t *testing.T) {
 16	setupTest(t)
 17
 18	t.Run("loads configuration successfully", func(t *testing.T) {
 19		homeDir := t.TempDir()
 20		t.Setenv("HOME", homeDir)
 21		configPath := filepath.Join(homeDir, ".termai.json")
 22
 23		configContent := `{
 24			"data": {
 25				"directory": "custom-dir"
 26			},
 27			"log": {
 28				"level": "debug"
 29			},
 30			"mcpServers": {
 31				"test-server": {
 32					"command": "test-command",
 33					"env": ["TEST_ENV=value"],
 34					"args": ["--arg1", "--arg2"],
 35					"type": "stdio",
 36					"url": "",
 37					"headers": {}
 38				},
 39				"sse-server": {
 40					"command": "",
 41					"env": [],
 42					"args": [],
 43					"type": "sse",
 44					"url": "https://api.example.com/events",
 45					"headers": {
 46						"Authorization": "Bearer token123",
 47						"Content-Type": "application/json"
 48					}
 49				}
 50			},
 51			"providers": {
 52				"anthropic": {
 53					"apiKey": "test-api-key",
 54					"enabled": true
 55				}
 56			},
 57			"model": {
 58				"coder": "claude-3-haiku",
 59				"task": "claude-3-haiku"
 60			}
 61		}`
 62		err := os.WriteFile(configPath, []byte(configContent), 0o644)
 63		require.NoError(t, err)
 64
 65		cfg = nil
 66		viper.Reset()
 67
 68		err = Load(false)
 69		require.NoError(t, err)
 70
 71		config := Get()
 72		assert.NotNil(t, config)
 73		assert.Equal(t, "custom-dir", config.Data.Directory)
 74		assert.Equal(t, "debug", config.Log.Level)
 75
 76		assert.Contains(t, config.MCPServers, "test-server")
 77		stdioServer := config.MCPServers["test-server"]
 78		assert.Equal(t, "test-command", stdioServer.Command)
 79		assert.Equal(t, []string{"TEST_ENV=value"}, stdioServer.Env)
 80		assert.Equal(t, []string{"--arg1", "--arg2"}, stdioServer.Args)
 81		assert.Equal(t, MCPStdio, stdioServer.Type)
 82		assert.Equal(t, "", stdioServer.URL)
 83		assert.Empty(t, stdioServer.Headers)
 84
 85		assert.Contains(t, config.MCPServers, "sse-server")
 86		sseServer := config.MCPServers["sse-server"]
 87		assert.Equal(t, "", sseServer.Command)
 88		assert.Empty(t, sseServer.Env)
 89		assert.Empty(t, sseServer.Args)
 90		assert.Equal(t, MCPSse, sseServer.Type)
 91		assert.Equal(t, "https://api.example.com/events", sseServer.URL)
 92		assert.Equal(t, map[string]string{
 93			"authorization": "Bearer token123",
 94			"content-type":  "application/json",
 95		}, sseServer.Headers)
 96
 97		assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
 98		provider := config.Providers[models.ModelProvider("anthropic")]
 99		assert.Equal(t, "test-api-key", provider.APIKey)
100		assert.True(t, provider.Enabled)
101
102		assert.NotNil(t, config.Model)
103		assert.Equal(t, models.Claude3Haiku, config.Model.Coder)
104		assert.Equal(t, models.Claude3Haiku, config.Model.Task)
105		assert.Equal(t, defaultMaxTokens, config.Model.CoderMaxTokens)
106	})
107
108	t.Run("loads configuration with environment variables", func(t *testing.T) {
109		homeDir := t.TempDir()
110		t.Setenv("HOME", homeDir)
111		configPath := filepath.Join(homeDir, ".termai.json")
112		err := os.WriteFile(configPath, []byte("{}"), 0o644)
113		require.NoError(t, err)
114
115		t.Setenv("ANTHROPIC_API_KEY", "env-anthropic-key")
116		t.Setenv("OPENAI_API_KEY", "env-openai-key")
117		t.Setenv("GEMINI_API_KEY", "env-gemini-key")
118
119		cfg = nil
120		viper.Reset()
121
122		err = Load(false)
123		require.NoError(t, err)
124
125		config := Get()
126		assert.NotNil(t, config)
127
128		assert.Equal(t, defaultDataDirectory, config.Data.Directory)
129		assert.Equal(t, defaultLogLevel, config.Log.Level)
130
131		assert.Contains(t, config.Providers, models.ModelProvider("anthropic"))
132		assert.Equal(t, "env-anthropic-key", config.Providers[models.ModelProvider("anthropic")].APIKey)
133		assert.True(t, config.Providers[models.ModelProvider("anthropic")].Enabled)
134
135		assert.Contains(t, config.Providers, models.ModelProvider("openai"))
136		assert.Equal(t, "env-openai-key", config.Providers[models.ModelProvider("openai")].APIKey)
137		assert.True(t, config.Providers[models.ModelProvider("openai")].Enabled)
138
139		assert.Contains(t, config.Providers, models.ModelProvider("gemini"))
140		assert.Equal(t, "env-gemini-key", config.Providers[models.ModelProvider("gemini")].APIKey)
141		assert.True(t, config.Providers[models.ModelProvider("gemini")].Enabled)
142
143		assert.Equal(t, models.Claude37Sonnet, config.Model.Coder)
144	})
145
146	t.Run("local config overrides global config", func(t *testing.T) {
147		homeDir := t.TempDir()
148		t.Setenv("HOME", homeDir)
149		globalConfigPath := filepath.Join(homeDir, ".termai.json")
150		globalConfig := `{
151			"data": {
152				"directory": "global-dir"
153			},
154			"log": {
155				"level": "info"
156			}
157		}`
158		err := os.WriteFile(globalConfigPath, []byte(globalConfig), 0o644)
159		require.NoError(t, err)
160
161		workDir := t.TempDir()
162		origDir, err := os.Getwd()
163		require.NoError(t, err)
164		defer os.Chdir(origDir)
165		err = os.Chdir(workDir)
166		require.NoError(t, err)
167
168		localConfigPath := filepath.Join(workDir, ".termai.json")
169		localConfig := `{
170			"data": {
171				"directory": "local-dir"
172			},
173			"log": {
174				"level": "debug"
175			}
176		}`
177		err = os.WriteFile(localConfigPath, []byte(localConfig), 0o644)
178		require.NoError(t, err)
179
180		cfg = nil
181		viper.Reset()
182
183		err = Load(false)
184		require.NoError(t, err)
185
186		config := Get()
187		assert.NotNil(t, config)
188
189		assert.Equal(t, "local-dir", config.Data.Directory)
190		assert.Equal(t, "debug", config.Log.Level)
191	})
192
193	t.Run("missing config file should not return error", func(t *testing.T) {
194		emptyDir := t.TempDir()
195		t.Setenv("HOME", emptyDir)
196
197		cfg = nil
198		viper.Reset()
199
200		err := Load(false)
201		assert.NoError(t, err)
202	})
203
204	t.Run("model priority and fallbacks", func(t *testing.T) {
205		testCases := []struct {
206			name             string
207			anthropicKey     string
208			openaiKey        string
209			geminiKey        string
210			expectedModel    models.ModelID
211			explicitModel    models.ModelID
212			useExplicitModel bool
213		}{
214			{
215				name:          "anthropic has priority",
216				anthropicKey:  "test-key",
217				openaiKey:     "test-key",
218				geminiKey:     "test-key",
219				expectedModel: models.Claude37Sonnet,
220			},
221			{
222				name:          "fallback to openai when no anthropic",
223				anthropicKey:  "",
224				openaiKey:     "test-key",
225				geminiKey:     "test-key",
226				expectedModel: models.GPT41,
227			},
228			{
229				name:          "fallback to gemini when no others",
230				anthropicKey:  "",
231				openaiKey:     "",
232				geminiKey:     "test-key",
233				expectedModel: models.GRMINI20Flash,
234			},
235			{
236				name:             "explicit model overrides defaults",
237				anthropicKey:     "test-key",
238				openaiKey:        "test-key",
239				geminiKey:        "test-key",
240				explicitModel:    models.GPT41,
241				useExplicitModel: true,
242				expectedModel:    models.GPT41,
243			},
244		}
245
246		for _, tc := range testCases {
247			t.Run(tc.name, func(t *testing.T) {
248				homeDir := t.TempDir()
249				t.Setenv("HOME", homeDir)
250				configPath := filepath.Join(homeDir, ".termai.json")
251
252				configContent := "{}"
253				if tc.useExplicitModel {
254					configContent = fmt.Sprintf(`{"model":{"coder":"%s"}}`, tc.explicitModel)
255				}
256
257				err := os.WriteFile(configPath, []byte(configContent), 0o644)
258				require.NoError(t, err)
259
260				if tc.anthropicKey != "" {
261					t.Setenv("ANTHROPIC_API_KEY", tc.anthropicKey)
262				} else {
263					t.Setenv("ANTHROPIC_API_KEY", "")
264				}
265
266				if tc.openaiKey != "" {
267					t.Setenv("OPENAI_API_KEY", tc.openaiKey)
268				} else {
269					t.Setenv("OPENAI_API_KEY", "")
270				}
271
272				if tc.geminiKey != "" {
273					t.Setenv("GEMINI_API_KEY", tc.geminiKey)
274				} else {
275					t.Setenv("GEMINI_API_KEY", "")
276				}
277
278				cfg = nil
279				viper.Reset()
280
281				err = Load(false)
282				require.NoError(t, err)
283
284				config := Get()
285				assert.NotNil(t, config)
286				assert.Equal(t, tc.expectedModel, config.Model.Coder)
287			})
288		}
289	})
290}
291
292func TestGet(t *testing.T) {
293	t.Run("get returns same config instance", func(t *testing.T) {
294		setupTest(t)
295		homeDir := t.TempDir()
296		t.Setenv("HOME", homeDir)
297		configPath := filepath.Join(homeDir, ".termai.json")
298		err := os.WriteFile(configPath, []byte("{}"), 0o644)
299		require.NoError(t, err)
300
301		cfg = nil
302		viper.Reset()
303
304		config1 := Get()
305		require.NotNil(t, config1)
306
307		config2 := Get()
308		require.NotNil(t, config2)
309
310		assert.Same(t, config1, config2)
311	})
312
313	t.Run("get loads config if not loaded", func(t *testing.T) {
314		setupTest(t)
315		homeDir := t.TempDir()
316		t.Setenv("HOME", homeDir)
317		configPath := filepath.Join(homeDir, ".termai.json")
318		configContent := `{"data":{"directory":"test-dir"}}`
319		err := os.WriteFile(configPath, []byte(configContent), 0o644)
320		require.NoError(t, err)
321
322		cfg = nil
323		viper.Reset()
324
325		config := Get()
326		require.NotNil(t, config)
327		assert.Equal(t, "test-dir", config.Data.Directory)
328	})
329}
330
331func TestWorkingDirectory(t *testing.T) {
332	t.Run("returns current working directory", func(t *testing.T) {
333		setupTest(t)
334		homeDir := t.TempDir()
335		t.Setenv("HOME", homeDir)
336		configPath := filepath.Join(homeDir, ".termai.json")
337		err := os.WriteFile(configPath, []byte("{}"), 0o644)
338		require.NoError(t, err)
339
340		cfg = nil
341		viper.Reset()
342
343		err = Load(false)
344		require.NoError(t, err)
345
346		wd := WorkingDirectory()
347		expectedWd, err := os.Getwd()
348		require.NoError(t, err)
349		assert.Equal(t, expectedWd, wd)
350	})
351}
352
353func TestWrite(t *testing.T) {
354	t.Run("writes config to file", func(t *testing.T) {
355		setupTest(t)
356		homeDir := t.TempDir()
357		t.Setenv("HOME", homeDir)
358		configPath := filepath.Join(homeDir, ".termai.json")
359		err := os.WriteFile(configPath, []byte("{}"), 0o644)
360		require.NoError(t, err)
361
362		cfg = nil
363		viper.Reset()
364
365		err = Load(false)
366		require.NoError(t, err)
367
368		viper.Set("data.directory", "modified-dir")
369
370		err = Write()
371		require.NoError(t, err)
372
373		content, err := os.ReadFile(configPath)
374		require.NoError(t, err)
375		assert.Contains(t, string(content), "modified-dir")
376	})
377}
378
379func TestMCPType(t *testing.T) {
380	t.Run("MCPType constants", func(t *testing.T) {
381		assert.Equal(t, MCPType("stdio"), MCPStdio)
382		assert.Equal(t, MCPType("sse"), MCPSse)
383	})
384
385	t.Run("MCPType JSON unmarshaling", func(t *testing.T) {
386		homeDir := t.TempDir()
387		t.Setenv("HOME", homeDir)
388		configPath := filepath.Join(homeDir, ".termai.json")
389
390		configContent := `{
391			"mcpServers": {
392				"stdio-server": {
393					"type": "stdio"
394				},
395				"sse-server": {
396					"type": "sse"
397				},
398				"invalid-server": {
399					"type": "invalid"
400				}
401			}
402		}`
403		err := os.WriteFile(configPath, []byte(configContent), 0o644)
404		require.NoError(t, err)
405
406		cfg = nil
407		viper.Reset()
408
409		err = Load(false)
410		require.NoError(t, err)
411
412		config := Get()
413		assert.NotNil(t, config)
414
415		assert.Equal(t, MCPStdio, config.MCPServers["stdio-server"].Type)
416		assert.Equal(t, MCPSse, config.MCPServers["sse-server"].Type)
417		assert.Equal(t, MCPType("invalid"), config.MCPServers["invalid-server"].Type)
418	})
419
420	t.Run("default MCPType", func(t *testing.T) {
421		homeDir := t.TempDir()
422		t.Setenv("HOME", homeDir)
423		configPath := filepath.Join(homeDir, ".termai.json")
424
425		configContent := `{
426			"mcpServers": {
427				"test-server": {
428					"command": "test-command"
429				}
430			}
431		}`
432		err := os.WriteFile(configPath, []byte(configContent), 0o644)
433		require.NoError(t, err)
434
435		cfg = nil
436		viper.Reset()
437
438		err = Load(false)
439		require.NoError(t, err)
440
441		config := Get()
442		assert.NotNil(t, config)
443
444		assert.Equal(t, MCPType(""), config.MCPServers["test-server"].Type)
445	})
446}
447
448func setupTest(t *testing.T) {
449	origHome := os.Getenv("HOME")
450	origXdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
451	origAnthropicKey := os.Getenv("ANTHROPIC_API_KEY")
452	origOpenAIKey := os.Getenv("OPENAI_API_KEY")
453	origGeminiKey := os.Getenv("GEMINI_API_KEY")
454
455	t.Cleanup(func() {
456		t.Setenv("HOME", origHome)
457		t.Setenv("XDG_CONFIG_HOME", origXdgConfigHome)
458		t.Setenv("ANTHROPIC_API_KEY", origAnthropicKey)
459		t.Setenv("OPENAI_API_KEY", origOpenAIKey)
460		t.Setenv("GEMINI_API_KEY", origGeminiKey)
461
462		cfg = nil
463		viper.Reset()
464	})
465}