Files
Agent-System/internal/sysadmin/settings_test.go
T
weichen f1404d5bd2 feat(sysadmin): add system admin aggregation API and system_settings store
- new internal/sysadmin package (overview/kb/features/settings) with small
  port interfaces; business packages stay unaware of sysadmin
- new routes /api/v1/admin/system/{overview,kb,features,settings} guarded
  by requireSystemAdmin
- goose 00049: system_settings table (key/jsonb value, updated_by/at)
- SettingSpec whitelist registry with DB > env > default precedence
- register sysadmin in module-boundaries.yaml (archtest rules)
2026-09-17 06:56:46 +00:00

211 lines
6.8 KiB
Go

package sysadmin
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
)
// memStore 进程内 SettingsStore,用于读优先级与写路径测试。
type memStore struct {
mu sync.Mutex
items map[string]memEntry
}
type memEntry struct {
value json.RawMessage
updatedBy *int64
updatedAt time.Time
}
func newMemStore() *memStore {
return &memStore{items: map[string]memEntry{}}
}
func (m *memStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, error) {
m.mu.Lock()
defer m.mu.Unlock()
entry, ok := m.items[key]
if !ok {
return nil, nil, nil, ErrSettingNotFound
}
at := entry.updatedAt
return entry.value, entry.updatedBy, &at, nil
}
func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) {
m.mu.Lock()
defer m.mu.Unlock()
at := time.Now()
m.items[key] = memEntry{value: append(json.RawMessage(nil), value...), updatedBy: &updatedBy, updatedAt: at}
return at, nil
}
func findSetting(t *testing.T, settings []Setting, key string) Setting {
t.Helper()
for _, s := range settings {
if s.Key == key {
return s
}
}
t.Fatalf("setting %s not found", key)
return Setting{}
}
func TestListSettings_FallsBackToDefaultWithoutStore(t *testing.T) {
svc := NewService(nil, Options{})
settings, err := svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
if len(settings) != len(defaultSettingSpecs()) {
t.Fatalf("expected %d settings, got %d", len(defaultSettingSpecs()), len(settings))
}
notice := findSetting(t, settings, "ui.maintenance_notice")
if notice.Value != "" || notice.Default != "" {
t.Fatalf("expected empty default notice, got value=%v default=%v", notice.Value, notice.Default)
}
if notice.UpdatedAt != nil || notice.UpdatedBy != nil {
t.Fatalf("default-resolved setting must not carry update metadata: %+v", notice)
}
reminder := findSetting(t, settings, "notifications.stage_advance_reminder")
if reminder.Value != true {
t.Fatalf("expected bool default true, got %v", reminder.Value)
}
}
func TestListSettings_DBOverridesDefault(t *testing.T) {
store := newMemStore()
if _, err := store.Upsert(context.Background(), "ui.maintenance_notice", json.RawMessage(`"停机维护中"`), 7); err != nil {
t.Fatalf("seed store: %v", err)
}
svc := NewService(nil, Options{Store: store})
settings, err := svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
notice := findSetting(t, settings, "ui.maintenance_notice")
if notice.Value != "停机维护中" {
t.Fatalf("expected DB value, got %v", notice.Value)
}
if notice.UpdatedBy == nil || *notice.UpdatedBy != 7 {
t.Fatalf("expected updatedBy=7, got %+v", notice.UpdatedBy)
}
if notice.UpdatedAt == nil {
t.Fatal("expected updatedAt from DB row")
}
// 未写入的 key 仍回退 default
limit := findSetting(t, settings, "kb.default_search_limit")
if limit.Value != 8.0 {
t.Fatalf("expected default 8, got %v", limit.Value)
}
}
func TestListSettings_EnvLayerBetweenDBAndDefault(t *testing.T) {
t.Setenv("SYSADMIN_TEST_FLAG", "true")
specs := []SettingSpec{
{Key: "test.env_flag", Label: "env 中间层", Group: "test", Type: SettingTypeBool, Default: false, Writable: true, EnvVar: "SYSADMIN_TEST_FLAG"},
}
svc := &Service{specs: specs}
settings, err := svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
if settings[0].Value != true {
t.Fatalf("expected env override true, got %v", settings[0].Value)
}
// DB 优先于 env
store := newMemStore()
if _, err := store.Upsert(context.Background(), "test.env_flag", json.RawMessage(`false`), 1); err != nil {
t.Fatalf("seed store: %v", err)
}
svc = &Service{specs: specs, store: store}
settings, err = svc.ListSettings(context.Background())
if err != nil {
t.Fatalf("ListSettings: %v", err)
}
if settings[0].Value != false {
t.Fatalf("expected DB to win over env, got %v", settings[0].Value)
}
}
func TestUpdateSetting_UnknownKey(t *testing.T) {
svc := NewService(nil, Options{Store: newMemStore()})
_, err := svc.UpdateSetting(context.Background(), "no.such.key", json.RawMessage(`"x"`), 1)
if !errors.Is(err, ErrUnknownSetting) {
t.Fatalf("expected ErrUnknownSetting, got %v", err)
}
}
func TestUpdateSetting_ReadOnlyRejected(t *testing.T) {
specs := []SettingSpec{
{Key: "test.locked", Label: "只读项", Group: "test", Type: SettingTypeString, Default: "a", Writable: false},
}
svc := &Service{specs: specs, store: newMemStore()}
_, err := svc.UpdateSetting(context.Background(), "test.locked", json.RawMessage(`"b"`), 1)
if !errors.Is(err, ErrSettingReadOnly) {
t.Fatalf("expected ErrSettingReadOnly, got %v", err)
}
}
func TestUpdateSetting_TypeValidation(t *testing.T) {
svc := NewService(nil, Options{Store: newMemStore()})
cases := []struct {
key string
raw string
}{
{"ui.maintenance_notice", `123`}, // string 声明收到 number
{"ui.read_only_mode", `"true"`}, // bool 声明收到 string
{"kb.default_search_limit", `"8"`}, // number 声明收到 string
{"ui.maintenance_notice", ``}, // 空 body value
{"ui.maintenance_notice", `{"a":1}`}, // string 声明收到 object
}
for _, tc := range cases {
_, err := svc.UpdateSetting(context.Background(), tc.key, json.RawMessage(tc.raw), 1)
if !errors.Is(err, ErrInvalidSettingValue) {
t.Fatalf("%s <- %s: expected ErrInvalidSettingValue, got %v", tc.key, tc.raw, err)
}
}
}
func TestUpdateSetting_WithoutStore(t *testing.T) {
svc := NewService(nil, Options{})
_, err := svc.UpdateSetting(context.Background(), "ui.maintenance_notice", json.RawMessage(`"x"`), 1)
if !errors.Is(err, ErrStoreUnavailable) {
t.Fatalf("expected ErrStoreUnavailable, got %v", err)
}
}
func TestUpdateSetting_PersistsAndReturnsUpdatedView(t *testing.T) {
svc := NewService(nil, Options{Store: newMemStore()})
setting, err := svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`12`), 42)
if err != nil {
t.Fatalf("UpdateSetting: %v", err)
}
if setting.Value != 12.0 {
t.Fatalf("expected 12, got %v", setting.Value)
}
if setting.Default != 8.0 {
t.Fatalf("default must stay 8, got %v", setting.Default)
}
if setting.UpdatedBy == nil || *setting.UpdatedBy != 42 {
t.Fatalf("expected updatedBy=42, got %+v", setting.UpdatedBy)
}
if setting.UpdatedAt == nil {
t.Fatal("expected updatedAt after write")
}
// 覆盖写:值更新、审计字段跟随
setting, err = svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`20`), 43)
if err != nil {
t.Fatalf("second UpdateSetting: %v", err)
}
if setting.Value != 20.0 || setting.UpdatedBy == nil || *setting.UpdatedBy != 43 {
t.Fatalf("expected overwrite (20, by 43), got (%v, %+v)", setting.Value, setting.UpdatedBy)
}
}