Files
sub2api/backend/internal/service/openai_codex_version_sync_service_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

481 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/latestlatest 为 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,不得写入")
}