openai_codex_version_sync_service_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "sync"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // --- mock: 只实现同步任务用到的读写,其余方法不应被调用 ---
  11. type codexVersionSyncSettingRepoStub struct {
  12. SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
  13. mu sync.Mutex
  14. values map[string]string
  15. getErr error
  16. setErr error
  17. updatedAt time.Time
  18. writes []string
  19. }
  20. func newCodexVersionSyncSettingRepoStub(values map[string]string) *codexVersionSyncSettingRepoStub {
  21. if values == nil {
  22. values = map[string]string{}
  23. }
  24. return &codexVersionSyncSettingRepoStub{values: values}
  25. }
  26. func (r *codexVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
  27. r.mu.Lock()
  28. defer r.mu.Unlock()
  29. if r.getErr != nil {
  30. return "", r.getErr
  31. }
  32. return r.values[key], nil
  33. }
  34. func (r *codexVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error {
  35. r.mu.Lock()
  36. defer r.mu.Unlock()
  37. if r.setErr != nil {
  38. return r.setErr
  39. }
  40. r.values[key] = value
  41. r.writes = append(r.writes, value)
  42. return nil
  43. }
  44. func (r *codexVersionSyncSettingRepoStub) syncedWrites() []string {
  45. r.mu.Lock()
  46. defer r.mu.Unlock()
  47. return append([]string(nil), r.writes...)
  48. }
  49. type codexVersionSyncGitHubStub struct {
  50. GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用)
  51. // 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」,
  52. // 使调用落到回退的列表扫描上。
  53. latest *GitHubRelease
  54. latestErr error
  55. latestCalls int
  56. releases []*GitHubRelease
  57. err error
  58. calls int
  59. }
  60. func (c *codexVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) {
  61. c.latestCalls++
  62. if c.latestErr != nil {
  63. return nil, c.latestErr
  64. }
  65. return c.latest, nil
  66. }
  67. func (c *codexVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) {
  68. c.calls++
  69. if c.err != nil {
  70. return nil, c.err
  71. }
  72. return c.releases, nil
  73. }
  74. func newCodexVersionSyncService(
  75. repo SettingRepository,
  76. github GitHubReleaseClient,
  77. ) *OpenAICodexVersionSyncService {
  78. return NewOpenAICodexVersionSyncService(repo, &SettingService{}, github, openAICodexVersionSyncInterval)
  79. }
  80. // 同仓库同时发布预发布版与其他组件的 tag,必须只认 rust-v 前缀的稳定版,
  81. // 否则会把无关组件的版本号(如 rusty-v8-v150.4.0)当成客户端版本同步出去。
  82. func TestLatestCodexStableReleaseVersion(t *testing.T) {
  83. releases := []*GitHubRelease{
  84. {TagName: "rusty-v8-v150.4.0"},
  85. {TagName: "rust-v0.147.0-alpha.4", Prerelease: true},
  86. {TagName: "rust-v0.146.0"},
  87. {TagName: "rust-v0.145.0"},
  88. {TagName: "rust-v0.999.0", Draft: true},
  89. {TagName: "not-a-tag"},
  90. nil,
  91. }
  92. require.Equal(t, "0.146.0", latestCodexStableReleaseVersion(releases))
  93. require.Empty(t, latestCodexStableReleaseVersion(nil))
  94. require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rusty-v8-v150.4.0"}}))
  95. // 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。
  96. require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rust-v0.147.0-alpha.4"}}))
  97. }
  98. func TestOpenAICodexVersionSyncWritesLatestStableVersion(t *testing.T) {
  99. repo := newCodexVersionSyncSettingRepoStub(nil)
  100. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{
  101. {TagName: "rust-v0.145.0"},
  102. {TagName: "rust-v0.146.0"},
  103. }}
  104. newCodexVersionSyncService(repo, github).runOnce()
  105. require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
  106. }
  107. // 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。
  108. func TestOpenAICodexVersionSyncNeverMovesBackwards(t *testing.T) {
  109. repo := newCodexVersionSyncSettingRepoStub(map[string]string{
  110. SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
  111. })
  112. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}}
  113. newCodexVersionSyncService(repo, github).runOnce()
  114. require.Empty(t, repo.syncedWrites())
  115. }
  116. func TestOpenAICodexVersionSyncSkippedWhenDisabled(t *testing.T) {
  117. repo := newCodexVersionSyncSettingRepoStub(map[string]string{
  118. SettingKeyOpenAICodexVersionAutoSyncEnabled: "false",
  119. })
  120. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
  121. newCodexVersionSyncService(repo, github).runOnce()
  122. require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游")
  123. require.Zero(t, github.calls, "关闭自动同步后不应请求上游")
  124. require.Empty(t, repo.syncedWrites())
  125. }
  126. // 面板开关缺失或为空一律视为开启,与设置默认值一致。
  127. func TestOpenAICodexVersionSyncEnabledByDefault(t *testing.T) {
  128. for _, value := range []string{"", "true"} {
  129. repo := newCodexVersionSyncSettingRepoStub(map[string]string{
  130. SettingKeyOpenAICodexVersionAutoSyncEnabled: value,
  131. })
  132. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
  133. newCodexVersionSyncService(repo, github).runOnce()
  134. require.Equal(t, []string{"0.146.0"}, repo.syncedWrites(), "开关值 %q", value)
  135. }
  136. }
  137. // 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。
  138. func TestOpenAICodexVersionSyncKeepsValueOnFetchError(t *testing.T) {
  139. repo := newCodexVersionSyncSettingRepoStub(map[string]string{
  140. SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
  141. })
  142. github := &codexVersionSyncGitHubStub{
  143. latestErr: errors.New("network down"),
  144. err: errors.New("network down"),
  145. }
  146. newCodexVersionSyncService(repo, github).runOnce()
  147. require.Equal(t, 1, github.latestCalls)
  148. require.Equal(t, 1, github.calls)
  149. require.Empty(t, repo.syncedWrites())
  150. value, err := repo.GetValue(context.Background(), SettingKeyOpenAICodexClientVersionSynced)
  151. require.NoError(t, err)
  152. require.Equal(t, "0.146.0", value)
  153. }
  154. // 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布,
  155. // 因此不受该仓库预发布密度影响,也不必再去拉 ~10MB 的列表页。
  156. func TestOpenAICodexVersionSyncUsesLatestReleaseEndpoint(t *testing.T) {
  157. repo := newCodexVersionSyncSettingRepoStub(nil)
  158. github := &codexVersionSyncGitHubStub{
  159. latest: &GitHubRelease{TagName: "rust-v0.146.0"},
  160. // 列表若被调用会给出不同答案,用于证明取值确实来自主路径。
  161. releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}},
  162. }
  163. newCodexVersionSyncService(repo, github).runOnce()
  164. require.Equal(t, 1, github.latestCalls)
  165. require.Zero(t, github.calls, "主路径可用时不应再拉列表页")
  166. require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
  167. }
  168. // 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,主路径拿不到客户端稳定版时
  169. // 必须继续扫一页 release,否则版本号会静默停更。三种拿不到的形态都要回退。
  170. func TestOpenAICodexVersionSyncFallsBackToReleaseList(t *testing.T) {
  171. tests := []struct {
  172. name string
  173. latest *GitHubRelease
  174. latestErr error
  175. }{
  176. // 同仓库其他组件(rusty-v8-*)某天发了正式 release 而成为 latest。
  177. {name: "latest 属于其他 tag 家族", latest: &GitHubRelease{TagName: "rusty-v8-v150.4.0"}},
  178. // 端点语义若变化(返回 draft/prerelease),过滤仍然拦得住,并照常回退。
  179. {name: "latest 是预发布", latest: &GitHubRelease{TagName: "rust-v0.147.0-alpha.4", Prerelease: true}},
  180. {name: "latest 抓取失败", latestErr: errors.New("network down")},
  181. // 上游返回空对象:不得因此 panic,按拿不到处理。
  182. {name: "latest 为空", latest: nil},
  183. }
  184. for _, tt := range tests {
  185. t.Run(tt.name, func(t *testing.T) {
  186. repo := newCodexVersionSyncSettingRepoStub(nil)
  187. github := &codexVersionSyncGitHubStub{
  188. latest: tt.latest,
  189. latestErr: tt.latestErr,
  190. releases: []*GitHubRelease{
  191. {TagName: "rust-v0.147.0-alpha.4", Prerelease: true},
  192. {TagName: "rust-v0.146.0"},
  193. {TagName: "rust-v0.145.0"},
  194. },
  195. }
  196. newCodexVersionSyncService(repo, github).runOnce()
  197. require.Equal(t, 1, github.latestCalls)
  198. require.Equal(t, 1, github.calls)
  199. require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
  200. })
  201. }
  202. }
  203. // 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验,
  204. // 不能因为「端点保证是正式发布」就直接采信 tag。
  205. func TestOpenAICodexVersionSyncLatestSharesFiltering(t *testing.T) {
  206. repo := newCodexVersionSyncSettingRepoStub(nil)
  207. github := &codexVersionSyncGitHubStub{
  208. // 仓库里确实存在 rust-vv0.99.0-alpha.8 / rust-vrust-v0.145.0-alpha.6 这类畸形 tag:
  209. // 剥掉前缀后不是合法版本号,必须被拒绝而不是写成 v0.99.0。
  210. latest: &GitHubRelease{TagName: "rust-vv0.99.0"},
  211. releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}},
  212. }
  213. newCodexVersionSyncService(repo, github).runOnce()
  214. require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
  215. }
  216. // 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。
  217. func TestOpenAICodexVersionSyncStartRequiresDependencies(t *testing.T) {
  218. require.NotPanics(t, func() {
  219. svc := NewOpenAICodexVersionSyncService(nil, nil, nil, openAICodexVersionSyncInterval)
  220. svc.Start()
  221. svc.Stop()
  222. })
  223. }
  224. // --- mock: 版本号读取只用到 GetMultiple ---
  225. type codexVersionSettingRepoStub struct {
  226. SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
  227. values map[string]string
  228. err error
  229. }
  230. func (r *codexVersionSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
  231. if r.err != nil {
  232. return nil, r.err
  233. }
  234. out := make(map[string]string, len(keys))
  235. for _, key := range keys {
  236. out[key] = r.values[key]
  237. }
  238. return out, nil
  239. }
  240. // 规范 UA 解析会先读面板的完整 UA 键。
  241. func (r *codexVersionSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
  242. if r.err != nil {
  243. return "", r.err
  244. }
  245. return r.values[key], nil
  246. }
  247. // 版本号优先级:管理员面板覆写 → 自动同步值 → 内置常量。
  248. // 管理员覆写必须压过同步值,否则「固定版本」的诉求会被 3 小时后的同步冲掉。
  249. func TestGetOpenAICodexClientVersionPriority(t *testing.T) {
  250. tests := []struct {
  251. name string
  252. override string
  253. synced string
  254. want string
  255. }{
  256. {name: "面板覆写优先", override: "0.150.0", synced: "0.146.0", want: "0.150.0"},
  257. {name: "覆写为空时用同步值", synced: "0.146.0", want: "0.146.0"},
  258. {name: "两者皆空时用内置常量", want: codexCLIVersion},
  259. {name: "非法覆写回退同步值", override: "latest", synced: "0.146.0", want: "0.146.0"},
  260. {name: "非法同步值回退内置常量", synced: "not-a-version", want: codexCLIVersion},
  261. }
  262. for _, tt := range tests {
  263. t.Run(tt.name, func(t *testing.T) {
  264. svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
  265. SettingKeyOpenAICodexClientVersion: tt.override,
  266. SettingKeyOpenAICodexClientVersionSynced: tt.synced,
  267. }}, nil)
  268. require.Equal(t, tt.want, svc.GetOpenAICodexClientVersion(context.Background()))
  269. })
  270. }
  271. }
  272. // 读取失败必须回退内置常量,不能返回空串把畸形身份拼给上游。
  273. func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) {
  274. svc := NewSettingService(&codexVersionSettingRepoStub{err: errors.New("db down")}, nil)
  275. require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background()))
  276. }
  277. // 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 TUI 形态。
  278. func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) {
  279. svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
  280. SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
  281. }}, nil)
  282. require.Equal(t,
  283. "codex-tui/0.200.1"+codexCLIUserAgentSuffix,
  284. svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
  285. )
  286. }
  287. // 回归:面板完整 UA 是唯一能改 OS / 架构 / 终端指纹的地方,必须保留;但它填写于某个
  288. // 历史版本,逐字沿用会绕过版本自动同步、把出站身份永久钉死在陈旧版本上——而陈旧身份
  289. // 正是上游优先降载的那一侧。因此只借它的指纹,版本段一律用生效版本重建。
  290. func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) {
  291. t.Run("陈旧面板 UA 跟随生效版本", func(t *testing.T) {
  292. svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
  293. // 历史面板 placeholder 的原文,照抄填写过的存量部署就是这个值。
  294. SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
  295. SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
  296. }}, nil)
  297. require.Equal(t,
  298. "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
  299. svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
  300. )
  301. })
  302. t.Run("自定义指纹原样保留", func(t *testing.T) {
  303. svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
  304. SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.140.0 (Mac OS X 15.1.0; arm64) iTerm.app",
  305. SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
  306. }}, nil)
  307. require.Equal(t,
  308. "codex_cli_rs/0.200.1 (Mac OS X 15.1.0; arm64) iTerm.app",
  309. svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
  310. )
  311. })
  312. t.Run("TUI UA 的首尾两个版本号同时更新", func(t *testing.T) {
  313. svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
  314. SettingKeyOpenAICodexUserAgent: "codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)",
  315. SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
  316. }}, nil)
  317. require.Equal(t,
  318. "codex-tui/0.200.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.200.1)",
  319. svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
  320. )
  321. })
  322. // 面板版本号覆写优先级仍然高于同步值:管理员固定版本的诉求不被重建绕开。
  323. t.Run("面板版本号覆写优先", func(t *testing.T) {
  324. svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
  325. SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
  326. SettingKeyOpenAICodexClientVersion: "0.150.0",
  327. SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
  328. }}, nil)
  329. require.Equal(t,
  330. "codex_cli_rs/0.150.0 (Ubuntu 22.4.0; x86_64) xterm-256color",
  331. svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
  332. )
  333. })
  334. // 非 `{client}/{version}` 形态无法重建,原样返回,由收口整体回退规范身份。
  335. t.Run("非 Codex 形态原样返回", func(t *testing.T) {
  336. svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
  337. SettingKeyOpenAICodexUserAgent: "not-a-codex-client",
  338. }}, nil)
  339. require.Equal(t, "not-a-codex-client", svc.GetOpenAICodexCanonicalUserAgent(context.Background()))
  340. })
  341. }
  342. func (r *codexVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) {
  343. r.mu.Lock()
  344. defer r.mu.Unlock()
  345. if r.getErr != nil {
  346. return nil, r.getErr
  347. }
  348. value, ok := r.values[key]
  349. if !ok {
  350. return nil, ErrSettingNotFound
  351. }
  352. return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil
  353. }
  354. // 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步
  355. // 放大成对 GitHub 的连续请求。
  356. func TestOpenAICodexVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) {
  357. repo := newCodexVersionSyncSettingRepoStub(map[string]string{
  358. SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
  359. })
  360. repo.updatedAt = time.Now().Add(-time.Hour)
  361. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}}
  362. newCodexVersionSyncService(repo, github).runInitial()
  363. require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游")
  364. require.Empty(t, repo.syncedWrites())
  365. }
  366. func TestOpenAICodexVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) {
  367. t.Run("同步值已过期", func(t *testing.T) {
  368. repo := newCodexVersionSyncSettingRepoStub(map[string]string{
  369. SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
  370. })
  371. repo.updatedAt = time.Now().Add(-7 * time.Hour)
  372. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}}
  373. newCodexVersionSyncService(repo, github).runInitial()
  374. require.Equal(t, 1, github.calls)
  375. require.Equal(t, []string{"0.147.0"}, repo.syncedWrites())
  376. })
  377. // 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。
  378. t.Run("尚无同步值", func(t *testing.T) {
  379. repo := newCodexVersionSyncSettingRepoStub(nil)
  380. repo.updatedAt = time.Now()
  381. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
  382. newCodexVersionSyncService(repo, github).runInitial()
  383. require.Equal(t, 1, github.calls)
  384. require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
  385. })
  386. }
  387. // 版本比较必须按段取数字:字典序会把 0.99.0 判为大于 0.146.0,
  388. // 从而让「取最大值」和「只向前推进」两处逻辑同时判错。
  389. func TestCodexVersionComparisonIsNumericNotLexical(t *testing.T) {
  390. require.Greater(t, CompareVersions("0.146.0", "0.99.0"), 0)
  391. require.Equal(t, "0.146.0", latestCodexStableReleaseVersion([]*GitHubRelease{
  392. {TagName: "rust-v0.99.0"},
  393. {TagName: "rust-v0.146.0"},
  394. }))
  395. repo := newCodexVersionSyncSettingRepoStub(map[string]string{
  396. SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
  397. })
  398. github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.99.0"}}}
  399. newCodexVersionSyncService(repo, github).runOnce()
  400. require.Empty(t, repo.syncedWrites(), "0.99.0 低于已同步的 0.146.0,不得写入")
  401. }