Files
Cyrene_v2/internal/services/gateway/admin_memory_tuning_test.go
T
AskaEth eddf921229 feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)
主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。

此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。

后端(读写端点,契约待塔登记 v0.92):
- GET  /api/v1/admin/memories/tidy/tuning  读盘上 7 键值 + 取值规则 specs +
  缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT  同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
  个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
  memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
  SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
  (t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
  重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
  未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」

前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
  env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
  mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
  error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
  「没有候选可比」显式区分)

验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
2026-10-13 21:59:18 +08:00

525 lines
20 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 gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理「面板调优」读写端点(`GET|PUT /api/v1/admin/memories/tidy/tuning`)
// 的判据:
// ① 读侧如实回显**盘上值**(键缺位 ⇒ 该键不出现)+ 取值规则 specs + `.env` 兜底值,
// 且不回显同文件里不受管的键;
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动(含「不重写文件」);合法值 ⇒ 采用且保留
// 同文件其它键;非法值 ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
// ③ 盘上文件解不开 ⇒ 读/写都 `409 tuning_corrupt` 且绝不覆盖;
// ④ `null` 是清除哨兵(删键 ⇒ 回到下一档兜底);
// ⑤ RBAC 走 writeAuth(owner|admin)、写落审计 `memory.tidy.tuning`;
// ⑥ 热生效**如实**:接线了 ⇒ hot=true 且真把值交给运行期;没接线/失败 ⇒ hot=false +
// hotError,绝不把「落盘了」说成「生效了」。
// tuneEnvT 是一个注入了数据目录(= 面板配置文件落点所在)的网关测试环境。
type tuneEnvT struct {
srv *Server
ts *httptest.Server
dir string
jwt *auth.JWTService
}
// newTuneEnv 构造环境。`cfg.MemoryTidy` 被显式填上,用来验证读侧下发的
// 「缺位时 `.env` 那一档」确实取自进程生效配置(而不是网关自己编的数)。
func newTuneEnv(t *testing.T, deps Deps) *tuneEnvT {
t.Helper()
dir := t.TempDir()
cfg := cfgDefault()
cfg.MemoryTidy = config.MemoryTidyConfig{IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
deps.DataDir = dir
srv, ts, jwt := buildEnv(t, cfg, deps)
return &tuneEnvT{srv: srv, ts: ts, dir: dir, jwt: jwt}
}
func (e *tuneEnvT) url() string {
return e.ts.URL + "/api/v1/admin/memories/tidy/tuning"
}
func (e *tuneEnvT) path() string {
return filepath.Join(e.dir, filepath.FromSlash(runtimeconfig.MemoryTidyConfigRelPath))
}
func (e *tuneEnvT) token(t *testing.T, role auth.Role) string {
t.Helper()
return signAny(t, e.jwt, "acct-"+string(role), role)
}
// seed 写入一份面板配置并返回写后的字节(后续逐字节比对用)。
func (e *tuneEnvT) seed(t *testing.T, body string) []byte {
t.Helper()
if err := os.MkdirAll(filepath.Dir(e.path()), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(e.path(), []byte(body), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
return e.bytes(t)
}
// bytes 读回盘上的原始字节:「非法值不落盘」的证据就是它与之前相等。
func (e *tuneEnvT) bytes(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(e.path())
if err != nil {
t.Fatalf("read tuning file: %v", err)
}
return raw
}
func (e *tuneEnvT) put(t *testing.T, token, raw string) (*http.Response, []byte) {
t.Helper()
return doRaw(t, http.MethodPut, e.url(), raw, token)
}
func (e *tuneEnvT) get(t *testing.T, token string) (*http.Response, []byte) {
t.Helper()
return doReq(t, http.MethodGet, e.url(), nil, token)
}
// view 解一次成功响应的 data。
func (e *tuneEnvT) view(t *testing.T, body []byte) AdminMemoryTidyTuningView {
t.Helper()
var view AdminMemoryTidyTuningView
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
t.Fatalf("decode view: %v (body=%s)", err, body)
}
return view
}
// assertErrorCode 断言响应是错误信封且 code 匹配。
func assertErrorCode(t *testing.T, body []byte, want string) {
t.Helper()
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error: %v (body=%s)", err, body)
}
if eobj.Code != want {
t.Fatalf("error code = %q, want %q (body=%s)", eobj.Code, want, body)
}
}
// diskDoc 解出盘上的文档(键 → 原样 JSON 值)。
func (e *tuneEnvT) diskDoc(t *testing.T) map[string]json.RawMessage {
t.Helper()
doc := map[string]json.RawMessage{}
if err := json.Unmarshal(e.bytes(t), &doc); err != nil {
t.Fatalf("decode disk: %v", err)
}
return doc
}
// TestAdminMemoryTidyTuningRead_EchoesDiskValues 读侧:回显盘上 7 键(缺位不下发)、
// 下发取值规则与 `.env` 兜底值,且**不回显**同文件里不受管的键。
func TestAdminMemoryTidyTuningRead_EchoesDiskValues(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{
"intervalSec": 3600,
"quietStartHour": 23,
"quietEndHour": 7,
"ingestGate": false,
"t1PerWindow": 120
}`)
resp, body := env.get(t, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("read status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Path != runtimeconfig.MemoryTidyConfigRelPath || !view.Present {
t.Fatalf("view path/present = %q/%v", view.Path, view.Present)
}
if got := view.Values["intervalSec"]; got != float64(3600) {
t.Fatalf("values.intervalSec = %#v, want 3600", got)
}
if got := view.Values["ingestGate"]; got != false {
t.Fatalf("values.ingestGate = %#v, want false", got)
}
// 键缺位 ⇒ 不出现(「没配」与「配成 0」必须分得开)。
for _, absent := range []string{"quietIntervalSec", "autoApply", "tempTtlSec"} {
if _, ok := view.Values[absent]; ok {
t.Fatalf("values 不该出现 %s(盘上没这个键):%#v", absent, view.Values)
}
}
// 不受管的键(t1PerWindow)不进 values:这张卡改不了它,混进来会误导。
if _, ok := view.Values["t1PerWindow"]; ok {
t.Fatalf("values 不该出现不受管的 t1PerWindow:%#v", view.Values)
}
if len(view.Specs) != len(memoryTidyTuningManagedKeys()) {
t.Fatalf("specs = %d 条, want %d", len(view.Specs), len(memoryTidyTuningManagedKeys()))
}
if view.EnvValues["intervalSec"] != 86400 || view.EnvValues["quietStartHour"] != 23 {
t.Fatalf("envValues = %#v, want intervalSec=86400 / quietStartHour=23", view.EnvValues)
}
if view.Hot {
t.Fatal("只读响应里 hot 必须为 false(读不改变任何东西)")
}
}
// TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError 文件还没播种时:正常回 200、
// present=false、values 为空 —— 「还没配过」不是错误(与 runtimeconfig 的读取口径一致)。
func TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
resp, body := env.get(t, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Present || len(view.Values) != 0 {
t.Fatalf("view = %+v, want present=false / 无值", view)
}
}
// TestAdminMemoryTidyTuningRead_ViewerForbidden RBAC:viewer 403、匿名 401。
func TestAdminMemoryTidyTuningRead_ViewerForbidden(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600}`)
resp, body := env.get(t, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer read status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
resp, _ = env.get(t, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous read status = %d, want 401", resp.StatusCode)
}
}
// TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys 写侧「合法值 ⇒ 采用」:
// 7 键都能落盘,同文件里不受管的键**原样保留**(一次面板保存绝不抹掉别的旋钮)。
func TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"t1Judge": false, "t1PerWindow": 120}`)
// 用 admin 角色也走同一道门(writeAuth = owner|admin)。
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{
"intervalSec": 10800,
"quietIntervalSec": 3600,
"quietStartHour": 23,
"quietEndHour": 7,
"ingestGate": true,
"autoApply": true,
"tempTtlSec": 604800
}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("write status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
for key, want := range map[string]any{
"intervalSec": float64(10800), "quietIntervalSec": float64(3600),
"quietStartHour": float64(23), "quietEndHour": float64(7),
"ingestGate": true, "autoApply": true, "tempTtlSec": float64(604800),
} {
if got := view.Values[key]; got != want {
t.Fatalf("回显 values[%s] = %#v, want %#v", key, got, want)
}
}
doc := env.diskDoc(t)
if string(doc["intervalSec"]) != "10800" || string(doc["t1PerWindow"]) != "120" {
t.Fatalf("disk = %s", string(env.bytes(t)))
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", string(env.bytes(t)))
}
// 未接运行期 ⇒ 如实说「已落盘、重启后生效」。
if view.Hot || view.HotError == "" {
t.Fatalf("未接热生效时 hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
}
}
// TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched 三态之一:键**缺位** ⇒
// 不改动。空补丁连文件都不重写(mtime 都不动);只提交一个键时另一个键逐字节不动。
func TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{"intervalSec": 86400, "quietStartHour": 23}`)
past := time.Now().Add(-time.Hour)
if err := os.Chtimes(env.path(), past, past); err != nil {
t.Fatalf("chtimes: %v", err)
}
tok := env.token(t, auth.RoleOwner)
resp, body := env.put(t, tok, `{}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("空补丁改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
info, err := os.Stat(env.path())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().Equal(past) {
t.Fatalf("空补丁重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
}
if resp, body := env.put(t, tok, `{"quietStartHour": 22}`); resp.StatusCode != http.StatusOK {
t.Fatalf("partial patch status = %d (body=%s)", resp.StatusCode, body)
}
doc := env.diskDoc(t)
if string(doc["intervalSec"]) != "86400" || string(doc["quietStartHour"]) != "22" {
t.Fatalf("partial patch 结果 = %s", string(env.bytes(t)))
}
}
// TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk 三态之三(本文件的核心
// 证据):任何非法输入都 `400 invalid_payload`,且**盘上一个字节都不动**。
func TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk(t *testing.T) {
t.Parallel()
cases := []struct{ name, body string }{
{"字符串冒充整数", `{"intervalSec": "3600"}`},
{"小数不是整数", `{"tempTtlSec": 1.5}`},
{"周期为 0(会让整理空转)", `{"intervalSec": 0}`},
{"周期为负", `{"quietIntervalSec": -60}`},
{"钟点越上界", `{"quietStartHour": 24}`},
{"钟点越下界", `{"quietEndHour": -1}`},
{"钟点不是数字", `{"quietEndHour": "07"}`},
{"开关不是布尔", `{"ingestGate": "yes"}`},
{"未知键(拼错的 quietEnd)", `{"quietEnd": 7}`},
{"请求体不是对象", `[1, 2]`},
{"请求体是 null", `null`},
{"请求体是空串", ``},
{"合法键与非法键同时提交", `{"intervalSec": 3600, "autoApply": "on"}`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{
"intervalSec": 86400,
"quietStartHour": 23,
"quietEndHour": 7,
"t1Judge": false
}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), tc.body)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
})
}
// 盘上**没有**文件时,非法输入也不得创建它(「先落盘再校验」同样是落盘)。
t.Run("文件不存在时也不创建", func(t *testing.T) {
env := newTuneEnv(t, Deps{})
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 0}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if _, err := os.Stat(env.path()); !os.IsNotExist(err) {
t.Fatalf("非法输入创建了文件:err=%v", err)
}
})
}
// TestAdminMemoryTidyTuningWrite_NullClearsKey 清除哨兵:`null` 删掉该键(回到下一档
// 兜底),同文件其它键不受影响。
func TestAdminMemoryTidyTuningWrite_NullClearsKey(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600, "quietIntervalSec": 7200, "t1Judge": false}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": null}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("clear status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if _, ok := view.Values["intervalSec"]; ok {
t.Fatalf("清除后不该再回显 intervalSec:%#v", view.Values)
}
if got := view.Values["quietIntervalSec"]; got != float64(7200) {
t.Fatalf("清除一个键影响了别的键:%#v", view.Values)
}
doc := env.diskDoc(t)
if _, ok := doc["intervalSec"]; ok {
t.Fatalf("盘上仍有 intervalSec:%s", string(env.bytes(t)))
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("清除一个键抹掉了别的键:%s", string(env.bytes(t)))
}
}
// TestAdminMemoryTidyTuningWrite_RefusesCorruptFile 盘上那份运行期读不走时,**读与写
// 都**拒绝(`409 tuning_corrupt`)且绝不覆盖 —— 否则面板会说「已保存」而运行期仍在
// 整体回落 `.env` 并记 WARN。
func TestAdminMemoryTidyTuningWrite_RefusesCorruptFile(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, body string }{
{"类型写错", `{"intervalSec": "3600"}`},
{"根本不是 JSON", `not json at all`},
{"顶层不是对象", `[1, 2, 3]`},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newTuneEnv(t, Deps{})
before := env.seed(t, tc.body)
tok := env.token(t, auth.RoleOwner)
resp, body := env.put(t, tok, `{"intervalSec": 3600}`)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("write status = %d, want 409 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "tuning_corrupt")
if string(env.bytes(t)) != string(before) {
t.Fatalf("坏文件被覆盖了:%s", string(env.bytes(t)))
}
// 读侧同样如实报坏,而不是回一份空配置假装「没配过」。
rresp, rbody := env.get(t, tok)
if rresp.StatusCode != http.StatusConflict {
t.Fatalf("read status = %d, want 409 (body=%s)", rresp.StatusCode, rbody)
}
assertErrorCode(t, rbody, "tuning_corrupt")
})
}
}
// TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite 「保存同样的值」不该在盘上留
// 一次无意义的改写(内容逐字节相同 ⇒ 不落盘、mtime 不动)。
func TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600}`)
past := time.Now().Add(-time.Hour)
if err := os.Chtimes(env.path(), past, past); err != nil {
t.Fatalf("chtimes: %v", err)
}
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 3600}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
info, err := os.Stat(env.path())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().Equal(past) {
t.Fatalf("同值保存重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
}
if string(env.bytes(t)) != `{"intervalSec": 3600}` {
t.Fatalf("盘上内容变了:%s", string(env.bytes(t)))
}
}
// fakeTuningTidy 既是整理服务的只读出口,也是「可热生效」的实现(记录收到的值)。
type fakeTuningTidy struct {
fakeTidyDryRunner
gotBase *memory.TidyConfig
gotTuning *runtimeconfig.MemoryTidyTuning
err error
}
func (f *fakeTuningTidy) ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
f.gotBase = &base
f.gotTuning = &panel
return f.err
}
// fakeTuningMemories 同时实现 MemoryStore 与「入库侧可热生效」。
type fakeTuningMemories struct {
got *runtimeconfig.MemoryTidyTuning
err error
}
func (f *fakeTuningMemories) List(context.Context) ([]memory.MemoryItem, error) {
return []memory.MemoryItem{}, nil
}
func (f *fakeTuningMemories) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
f.got = &panel
return f.err
}
// TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits 接线了热生效实现时:保存后当场把
// **新值**交给运行期(hot=true),并落一条审计 `memory.tidy.tuning`/ok。
func TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits(t *testing.T) {
t.Parallel()
tidy := &fakeTuningTidy{}
mems := &fakeTuningMemories{}
env := newTuneEnv(t, Deps{MemoryTidy: tidy, Memories: mems})
env.seed(t, `{"intervalSec": 86400}`)
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{"intervalSec": 10800, "tempTtlSec": 259200}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if !view.Hot || view.HotError != "" {
t.Fatalf("hot/hotError = %v/%q, want true/空", view.Hot, view.HotError)
}
if tidy.gotTuning == nil || tidy.gotTuning.IntervalSec == nil || *tidy.gotTuning.IntervalSec != 10800 {
t.Fatalf("整理档没收到面板值:%#v", tidy.gotTuning)
}
if mems.got == nil || mems.got.TempTtlSec == nil || *mems.got.TempTtlSec != 259200 {
t.Fatalf("入库侧没收到面板值:%#v", mems.got)
}
// base 是 `.env` 层(进程生效配置投影),不是面板值本身。
if tidy.gotBase == nil || tidy.gotBase.IntervalSec != 86400 {
t.Fatalf("base 不是 .env 层:%#v", tidy.gotBase)
}
recs, err := env.srv.obs.audit.List(context.Background(), 50)
if err != nil {
t.Fatalf("list audit: %v", err)
}
found := false
for _, rec := range recs {
if rec.What == memoryTuningAuditAction && rec.Result == "ok" {
found = true
}
}
if !found {
t.Fatalf("审计链里没有 %s/ok 的记录:%#v", memoryTuningAuditAction, recs)
}
}
// TestAdminMemoryTidyTuningWrite_HotFailureIsHonest 热生效实现报错时:**值照旧留在盘上**
// (不丢),但响应如实 `hot:false` + 原因,绝不说成「已生效」。
func TestAdminMemoryTidyTuningWrite_HotFailureIsHonest(t *testing.T) {
t.Parallel()
tidy := &fakeTuningTidy{err: errors.New("boom")}
env := newTuneEnv(t, Deps{MemoryTidy: tidy})
env.seed(t, `{"intervalSec": 86400}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 10800}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Hot || view.HotError == "" {
t.Fatalf("hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
}
if got := view.Values["intervalSec"]; got != float64(10800) {
t.Fatalf("热生效失败不该丢值:%#v", view.Values)
}
}
// TestAdminMemoryTidyTuning_UnavailableWithoutDataDir 数据目录未接线时如实报「没有落点」,
// 绝不猜一个目录去写(端点据此回 500 tuning_unavailable)。
func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) {
t.Parallel()
srv, err := New(cfgDefault(), Deps{
Auth: auth.New(config.AuthConfig{AdminUser: "a", AdminPass: "b", JWTSecret: testSecret}),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
if _, ok := srv.memoryTidyTuningPath(); ok {
t.Fatal("没有数据目录时不该解析出落点")
}
}