- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
201 lines
6.0 KiB
Go
201 lines
6.0 KiB
Go
package claudecfg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
|
)
|
|
|
|
const originalSettings = `{
|
|
"theme": "dark",
|
|
"env": {
|
|
"ANTHROPIC_AUTH_TOKEN": "sk-official",
|
|
"ANTHROPIC_BASE_URL": "https://official.example.com",
|
|
"DISABLE_AUTOUPDATER": "1"
|
|
}
|
|
}
|
|
`
|
|
|
|
func newManager(t *testing.T) (*Manager, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, "settings.json")
|
|
if err := os.WriteFile(settingsPath, []byte(originalSettings), 0o600); err != nil {
|
|
t.Fatalf("write settings: %v", err)
|
|
}
|
|
return New(settingsPath, filepath.Join(dir, "backups")), settingsPath
|
|
}
|
|
|
|
func readSettings(t *testing.T, path string) map[string]any {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read settings: %v", err)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(data, &out); err != nil {
|
|
t.Fatalf("parse settings: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestApplyPreservesUnknownKeys(t *testing.T) {
|
|
mgr, path := newManager(t)
|
|
if _, err := mgr.Apply(ApplyOptions{
|
|
BaseURL: "http://127.0.0.1:8787",
|
|
Token: "crl_test",
|
|
Models: config.Models{Sonnet: "ds-chat", Haiku: "ds-haiku", Opus: "ds-opus", Default: "ds-chat"},
|
|
}); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
settings := readSettings(t, path)
|
|
if settings["theme"] != "dark" {
|
|
t.Errorf("theme lost: %v", settings["theme"])
|
|
}
|
|
env := settings["env"].(map[string]any)
|
|
if env["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787" {
|
|
t.Errorf("base url = %v", env["ANTHROPIC_BASE_URL"])
|
|
}
|
|
if env["ANTHROPIC_AUTH_TOKEN"] != "crl_test" {
|
|
t.Errorf("relay token = %v", env["ANTHROPIC_AUTH_TOKEN"])
|
|
}
|
|
if env["DISABLE_AUTOUPDATER"] != "1" {
|
|
t.Errorf("unrelated env key lost: %v", env)
|
|
}
|
|
if env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] != "ds-haiku" {
|
|
t.Errorf("haiku model = %v", env["ANTHROPIC_DEFAULT_HAIKU_MODEL"])
|
|
}
|
|
if env["ANTHROPIC_MODEL"] != "ds-chat" {
|
|
t.Errorf("main model = %v", env["ANTHROPIC_MODEL"])
|
|
}
|
|
}
|
|
|
|
func TestApplyCreatesBackupWithOriginalContent(t *testing.T) {
|
|
mgr, _ := newManager(t)
|
|
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
backups, err := mgr.Backups()
|
|
if err != nil {
|
|
t.Fatalf("Backups: %v", err)
|
|
}
|
|
if len(backups) != 1 {
|
|
t.Fatalf("backups = %d, want 1", len(backups))
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(mgr.backupDir, backups[0].Name))
|
|
if err != nil {
|
|
t.Fatalf("read backup: %v", err)
|
|
}
|
|
settings := map[string]any{}
|
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
t.Fatalf("parse backup: %v", err)
|
|
}
|
|
env := settings["env"].(map[string]any)
|
|
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
|
|
t.Errorf("backup holds post-apply content: %v", env)
|
|
}
|
|
}
|
|
|
|
func TestApplyIsIdempotent(t *testing.T) {
|
|
mgr, path := newManager(t)
|
|
opts := ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "ds-chat"}}
|
|
if _, err := mgr.Apply(opts); err != nil {
|
|
t.Fatalf("first Apply: %v", err)
|
|
}
|
|
afterFirst := readSettings(t, path)
|
|
if _, err := mgr.Apply(opts); err != nil {
|
|
t.Fatalf("second Apply: %v", err)
|
|
}
|
|
afterSecond := readSettings(t, path)
|
|
firstEnv, _ := json.Marshal(afterFirst["env"])
|
|
secondEnv, _ := json.Marshal(afterSecond["env"])
|
|
if string(firstEnv) != string(secondEnv) {
|
|
t.Errorf("env changed on re-apply:\n%s\n%s", firstEnv, secondEnv)
|
|
}
|
|
}
|
|
|
|
func TestRestore(t *testing.T) {
|
|
mgr, path := newManager(t)
|
|
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
name, err := mgr.Restore()
|
|
if err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
if name == "" {
|
|
t.Error("Restore returned no backup name")
|
|
}
|
|
env := readSettings(t, path)["env"].(map[string]any)
|
|
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
|
|
t.Errorf("base url after restore = %v", env["ANTHROPIC_BASE_URL"])
|
|
}
|
|
if env["ANTHROPIC_AUTH_TOKEN"] != "sk-official" {
|
|
t.Errorf("token after restore = %v", env["ANTHROPIC_AUTH_TOKEN"])
|
|
}
|
|
}
|
|
|
|
func TestRestoreWithoutBackupFails(t *testing.T) {
|
|
mgr, _ := newManager(t)
|
|
if _, err := mgr.Restore(); err == nil {
|
|
t.Fatal("Restore succeeded without backups")
|
|
}
|
|
}
|
|
|
|
func TestApplyModelRoles(t *testing.T) {
|
|
mgr, path := newManager(t)
|
|
// Only a default: every role falls back to it.
|
|
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "only-default"}}); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
env := readSettings(t, path)["env"].(map[string]any)
|
|
for _, key := range []string{
|
|
"ANTHROPIC_MODEL",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
} {
|
|
if env[key] != "only-default" {
|
|
t.Errorf("%s = %v, want only-default", key, env[key])
|
|
}
|
|
}
|
|
// Empty roles remove the keys again.
|
|
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Sonnet: "s"}}); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
env = readSettings(t, path)["env"].(map[string]any)
|
|
if _, ok := env["ANTHROPIC_DEFAULT_OPUS_MODEL"]; ok {
|
|
t.Error("opus key should be removed when the provider has no opus model")
|
|
}
|
|
}
|
|
|
|
func TestApplyCreatesMissingSettingsFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "settings.json")
|
|
mgr := New(path, filepath.Join(dir, "backups"))
|
|
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "m"}}); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("settings.json not created: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBackupsReturnsEmptySliceNotNil(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mgr := New(filepath.Join(dir, "settings.json"), filepath.Join(dir, "backups"))
|
|
backups, err := mgr.Backups()
|
|
if err != nil {
|
|
t.Fatalf("Backups: %v", err)
|
|
}
|
|
if backups == nil {
|
|
t.Fatal("Backups returned nil, which marshals to JSON null and breaks the UI")
|
|
}
|
|
if len(backups) != 0 {
|
|
t.Errorf("backups = %v, want empty", backups)
|
|
}
|
|
}
|