package service import ( "context" "errors" "sync" "testing" "time" "github.com/stretchr/testify/require" ) // --- mock: 只实现同步任务用到的读写,其余方法不应被调用 --- type codexVersionSyncSettingRepoStub struct { SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) mu sync.Mutex values map[string]string getErr error setErr error updatedAt time.Time writes []string } func newCodexVersionSyncSettingRepoStub(values map[string]string) *codexVersionSyncSettingRepoStub { if values == nil { values = map[string]string{} } return &codexVersionSyncSettingRepoStub{values: values} } func (r *codexVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { r.mu.Lock() defer r.mu.Unlock() if r.getErr != nil { return "", r.getErr } return r.values[key], nil } func (r *codexVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error { r.mu.Lock() defer r.mu.Unlock() if r.setErr != nil { return r.setErr } r.values[key] = value r.writes = append(r.writes, value) return nil } func (r *codexVersionSyncSettingRepoStub) syncedWrites() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.writes...) } type codexVersionSyncGitHubStub struct { GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用) // 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」, // 使调用落到回退的列表扫描上。 latest *GitHubRelease latestErr error latestCalls int releases []*GitHubRelease err error calls int } func (c *codexVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) { c.latestCalls++ if c.latestErr != nil { return nil, c.latestErr } return c.latest, nil } func (c *codexVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) { c.calls++ if c.err != nil { return nil, c.err } return c.releases, nil } func newCodexVersionSyncService( repo SettingRepository, github GitHubReleaseClient, ) *OpenAICodexVersionSyncService { return NewOpenAICodexVersionSyncService(repo, &SettingService{}, github, openAICodexVersionSyncInterval) } // 同仓库同时发布预发布版与其他组件的 tag,必须只认 rust-v 前缀的稳定版, // 否则会把无关组件的版本号(如 rusty-v8-v150.4.0)当成客户端版本同步出去。 func TestLatestCodexStableReleaseVersion(t *testing.T) { releases := []*GitHubRelease{ {TagName: "rusty-v8-v150.4.0"}, {TagName: "rust-v0.147.0-alpha.4", Prerelease: true}, {TagName: "rust-v0.146.0"}, {TagName: "rust-v0.145.0"}, {TagName: "rust-v0.999.0", Draft: true}, {TagName: "not-a-tag"}, nil, } require.Equal(t, "0.146.0", latestCodexStableReleaseVersion(releases)) require.Empty(t, latestCodexStableReleaseVersion(nil)) require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rusty-v8-v150.4.0"}})) // 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。 require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rust-v0.147.0-alpha.4"}})) } func TestOpenAICodexVersionSyncWritesLatestStableVersion(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(nil) github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{ {TagName: "rust-v0.145.0"}, {TagName: "rust-v0.146.0"}, }} newCodexVersionSyncService(repo, github).runOnce() require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) } // 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。 func TestOpenAICodexVersionSyncNeverMovesBackwards(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(map[string]string{ SettingKeyOpenAICodexClientVersionSynced: "0.146.0", }) github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}} newCodexVersionSyncService(repo, github).runOnce() require.Empty(t, repo.syncedWrites()) } func TestOpenAICodexVersionSyncSkippedWhenDisabled(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(map[string]string{ SettingKeyOpenAICodexVersionAutoSyncEnabled: "false", }) github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} newCodexVersionSyncService(repo, github).runOnce() require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游") require.Zero(t, github.calls, "关闭自动同步后不应请求上游") require.Empty(t, repo.syncedWrites()) } // 面板开关缺失或为空一律视为开启,与设置默认值一致。 func TestOpenAICodexVersionSyncEnabledByDefault(t *testing.T) { for _, value := range []string{"", "true"} { repo := newCodexVersionSyncSettingRepoStub(map[string]string{ SettingKeyOpenAICodexVersionAutoSyncEnabled: value, }) github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} newCodexVersionSyncService(repo, github).runOnce() require.Equal(t, []string{"0.146.0"}, repo.syncedWrites(), "开关值 %q", value) } } // 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。 func TestOpenAICodexVersionSyncKeepsValueOnFetchError(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(map[string]string{ SettingKeyOpenAICodexClientVersionSynced: "0.146.0", }) github := &codexVersionSyncGitHubStub{ latestErr: errors.New("network down"), err: errors.New("network down"), } newCodexVersionSyncService(repo, github).runOnce() require.Equal(t, 1, github.latestCalls) require.Equal(t, 1, github.calls) require.Empty(t, repo.syncedWrites()) value, err := repo.GetValue(context.Background(), SettingKeyOpenAICodexClientVersionSynced) require.NoError(t, err) require.Equal(t, "0.146.0", value) } // 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布, // 因此不受该仓库预发布密度影响,也不必再去拉 ~10MB 的列表页。 func TestOpenAICodexVersionSyncUsesLatestReleaseEndpoint(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(nil) github := &codexVersionSyncGitHubStub{ latest: &GitHubRelease{TagName: "rust-v0.146.0"}, // 列表若被调用会给出不同答案,用于证明取值确实来自主路径。 releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}, } newCodexVersionSyncService(repo, github).runOnce() require.Equal(t, 1, github.latestCalls) require.Zero(t, github.calls, "主路径可用时不应再拉列表页") require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) } // 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,主路径拿不到客户端稳定版时 // 必须继续扫一页 release,否则版本号会静默停更。三种拿不到的形态都要回退。 func TestOpenAICodexVersionSyncFallsBackToReleaseList(t *testing.T) { tests := []struct { name string latest *GitHubRelease latestErr error }{ // 同仓库其他组件(rusty-v8-*)某天发了正式 release 而成为 latest。 {name: "latest 属于其他 tag 家族", latest: &GitHubRelease{TagName: "rusty-v8-v150.4.0"}}, // 端点语义若变化(返回 draft/prerelease),过滤仍然拦得住,并照常回退。 {name: "latest 是预发布", latest: &GitHubRelease{TagName: "rust-v0.147.0-alpha.4", Prerelease: true}}, {name: "latest 抓取失败", latestErr: errors.New("network down")}, // 上游返回空对象:不得因此 panic,按拿不到处理。 {name: "latest 为空", latest: nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(nil) github := &codexVersionSyncGitHubStub{ latest: tt.latest, latestErr: tt.latestErr, releases: []*GitHubRelease{ {TagName: "rust-v0.147.0-alpha.4", Prerelease: true}, {TagName: "rust-v0.146.0"}, {TagName: "rust-v0.145.0"}, }, } newCodexVersionSyncService(repo, github).runOnce() require.Equal(t, 1, github.latestCalls) require.Equal(t, 1, github.calls) require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) }) } } // 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验, // 不能因为「端点保证是正式发布」就直接采信 tag。 func TestOpenAICodexVersionSyncLatestSharesFiltering(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(nil) github := &codexVersionSyncGitHubStub{ // 仓库里确实存在 rust-vv0.99.0-alpha.8 / rust-vrust-v0.145.0-alpha.6 这类畸形 tag: // 剥掉前缀后不是合法版本号,必须被拒绝而不是写成 v0.99.0。 latest: &GitHubRelease{TagName: "rust-vv0.99.0"}, releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}, } newCodexVersionSyncService(repo, github).runOnce() require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) } // 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。 func TestOpenAICodexVersionSyncStartRequiresDependencies(t *testing.T) { require.NotPanics(t, func() { svc := NewOpenAICodexVersionSyncService(nil, nil, nil, openAICodexVersionSyncInterval) svc.Start() svc.Stop() }) } // --- mock: 版本号读取只用到 GetMultiple --- type codexVersionSettingRepoStub struct { SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) values map[string]string err error } func (r *codexVersionSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { if r.err != nil { return nil, r.err } out := make(map[string]string, len(keys)) for _, key := range keys { out[key] = r.values[key] } return out, nil } // 规范 UA 解析会先读面板的完整 UA 键。 func (r *codexVersionSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { if r.err != nil { return "", r.err } return r.values[key], nil } // 版本号优先级:管理员面板覆写 → 自动同步值 → 内置常量。 // 管理员覆写必须压过同步值,否则「固定版本」的诉求会被 3 小时后的同步冲掉。 func TestGetOpenAICodexClientVersionPriority(t *testing.T) { tests := []struct { name string override string synced string want string }{ {name: "面板覆写优先", override: "0.150.0", synced: "0.146.0", want: "0.150.0"}, {name: "覆写为空时用同步值", synced: "0.146.0", want: "0.146.0"}, {name: "两者皆空时用内置常量", want: codexCLIVersion}, {name: "非法覆写回退同步值", override: "latest", synced: "0.146.0", want: "0.146.0"}, {name: "非法同步值回退内置常量", synced: "not-a-version", want: codexCLIVersion}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ SettingKeyOpenAICodexClientVersion: tt.override, SettingKeyOpenAICodexClientVersionSynced: tt.synced, }}, nil) require.Equal(t, tt.want, svc.GetOpenAICodexClientVersion(context.Background())) }) } } // 读取失败必须回退内置常量,不能返回空串把畸形身份拼给上游。 func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{err: errors.New("db down")}, nil) require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background())) } // 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 TUI 形态。 func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ SettingKeyOpenAICodexClientVersionSynced: "0.200.1", }}, nil) require.Equal(t, "codex-tui/0.200.1"+codexCLIUserAgentSuffix, svc.GetOpenAICodexCanonicalUserAgent(context.Background()), ) } // 回归:面板完整 UA 是唯一能改 OS / 架构 / 终端指纹的地方,必须保留;但它填写于某个 // 历史版本,逐字沿用会绕过版本自动同步、把出站身份永久钉死在陈旧版本上——而陈旧身份 // 正是上游优先降载的那一侧。因此只借它的指纹,版本段一律用生效版本重建。 func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) { t.Run("陈旧面板 UA 跟随生效版本", func(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ // 历史面板 placeholder 的原文,照抄填写过的存量部署就是这个值。 SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", SettingKeyOpenAICodexClientVersionSynced: "0.200.1", }}, nil) require.Equal(t, "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", svc.GetOpenAICodexCanonicalUserAgent(context.Background()), ) }) t.Run("自定义指纹原样保留", func(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.140.0 (Mac OS X 15.1.0; arm64) iTerm.app", SettingKeyOpenAICodexClientVersionSynced: "0.200.1", }}, nil) require.Equal(t, "codex_cli_rs/0.200.1 (Mac OS X 15.1.0; arm64) iTerm.app", svc.GetOpenAICodexCanonicalUserAgent(context.Background()), ) }) t.Run("TUI UA 的首尾两个版本号同时更新", func(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ SettingKeyOpenAICodexUserAgent: "codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)", SettingKeyOpenAICodexClientVersionSynced: "0.200.1", }}, nil) require.Equal(t, "codex-tui/0.200.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.200.1)", svc.GetOpenAICodexCanonicalUserAgent(context.Background()), ) }) // 面板版本号覆写优先级仍然高于同步值:管理员固定版本的诉求不被重建绕开。 t.Run("面板版本号覆写优先", func(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", SettingKeyOpenAICodexClientVersion: "0.150.0", SettingKeyOpenAICodexClientVersionSynced: "0.200.1", }}, nil) require.Equal(t, "codex_cli_rs/0.150.0 (Ubuntu 22.4.0; x86_64) xterm-256color", svc.GetOpenAICodexCanonicalUserAgent(context.Background()), ) }) // 非 `{client}/{version}` 形态无法重建,原样返回,由收口整体回退规范身份。 t.Run("非 Codex 形态原样返回", func(t *testing.T) { svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ SettingKeyOpenAICodexUserAgent: "not-a-codex-client", }}, nil) require.Equal(t, "not-a-codex-client", svc.GetOpenAICodexCanonicalUserAgent(context.Background())) }) } func (r *codexVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) { r.mu.Lock() defer r.mu.Unlock() if r.getErr != nil { return nil, r.getErr } value, ok := r.values[key] if !ok { return nil, ErrSettingNotFound } return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil } // 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步 // 放大成对 GitHub 的连续请求。 func TestOpenAICodexVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(map[string]string{ SettingKeyOpenAICodexClientVersionSynced: "0.146.0", }) repo.updatedAt = time.Now().Add(-time.Hour) github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}} newCodexVersionSyncService(repo, github).runInitial() require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游") require.Empty(t, repo.syncedWrites()) } func TestOpenAICodexVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) { t.Run("同步值已过期", func(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(map[string]string{ SettingKeyOpenAICodexClientVersionSynced: "0.146.0", }) repo.updatedAt = time.Now().Add(-7 * time.Hour) github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}} newCodexVersionSyncService(repo, github).runInitial() require.Equal(t, 1, github.calls) require.Equal(t, []string{"0.147.0"}, repo.syncedWrites()) }) // 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。 t.Run("尚无同步值", func(t *testing.T) { repo := newCodexVersionSyncSettingRepoStub(nil) repo.updatedAt = time.Now() github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} newCodexVersionSyncService(repo, github).runInitial() require.Equal(t, 1, github.calls) require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) }) } // 版本比较必须按段取数字:字典序会把 0.99.0 判为大于 0.146.0, // 从而让「取最大值」和「只向前推进」两处逻辑同时判错。 func TestCodexVersionComparisonIsNumericNotLexical(t *testing.T) { require.Greater(t, CompareVersions("0.146.0", "0.99.0"), 0) require.Equal(t, "0.146.0", latestCodexStableReleaseVersion([]*GitHubRelease{ {TagName: "rust-v0.99.0"}, {TagName: "rust-v0.146.0"}, })) repo := newCodexVersionSyncSettingRepoStub(map[string]string{ SettingKeyOpenAICodexClientVersionSynced: "0.146.0", }) github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.99.0"}}} newCodexVersionSyncService(repo, github).runOnce() require.Empty(t, repo.syncedWrites(), "0.99.0 低于已同步的 0.146.0,不得写入") }