package runtimeconfig import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "testing" ) // TestOwnerLaneStoreDefaultsWhenFileMissing 钉住默认档(裁决「甲」): // 文件不存在时读到的就是最保守的一档 —— 不对她说就不开口,且没有额外频率闸。 func TestOwnerLaneStoreDefaultsWhenFileMissing(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "config", "owner_lane.json") s := NewOwnerLaneStore(path, nil) got := s.PolicyFor("qq-obv11", "672432767") if got.ReplyToUnaddressed || got.MinReplyGapSec != 0 { t.Fatalf("默认档 = %+v, want 零值(不对她说就不开口)", got) } } // TestOwnerLaneStoreSetPersistsAndReloads 钉住「可读可改」:Set 落盘后,新建的 // store 能读回同一个每群覆盖;未覆盖的群仍走全局 default。 func TestOwnerLaneStoreSetPersistsAndReloads(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "config", "owner_lane.json") s := NewOwnerLaneStore(path, nil) want := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 120} if err := s.Set("qq-obv11", "672432767", want); err != nil { t.Fatalf("Set: %v", err) } if _, err := os.Stat(path); err != nil { t.Fatalf("owner_lane.json 未落盘:%v", err) } reloaded := NewOwnerLaneStore(path, nil) if got := reloaded.PolicyFor("qq-obv11", "672432767"); got != want { t.Fatalf("重载后该群策略 = %+v, want %+v", got, want) } // 未被覆盖的群仍走全局 default(零值)。 if got := reloaded.PolicyFor("qq-obv11", "another-group"); got != DefaultOwnerLanePolicy() { t.Fatalf("未覆盖群策略 = %+v, want 默认档", got) } } // TestOwnerLaneStoreGlobalDefault 钉住面板可改的「全局 default」那一档: // groupID 为空即写默认,所有未单独覆盖的群都跟着变。 func TestOwnerLaneStoreGlobalDefault(t *testing.T) { t.Parallel() s := NewOwnerLaneStore(filepath.Join(t.TempDir(), "config", "owner_lane.json"), nil) global := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 60} if err := s.Set("", "", global); err != nil { t.Fatalf("Set default: %v", err) } if got := s.PolicyFor("telegram", "-20"); got != global { t.Fatalf("全局默认生效值 = %+v, want %+v", got, global) } } // TestOwnerLaneStoreCorruptFileFallsBackToDefaults 钉住「配置坏了也不阻断」: // 畸形文件只退回默认档并留 WARN,绝不 panic、绝不返回错误阻断渠道启动。 func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "config", "owner_lane.json") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte("{ this is not json "), 0o600); err != nil { t.Fatalf("write: %v", err) } s := NewOwnerLaneStore(path, nil) if got := s.PolicyFor("qq-obv11", "g"); got != DefaultOwnerLanePolicy() { t.Fatalf("坏配置下策略 = %+v, want 默认档", got) } } // TestOwnerLaneStoreConcurrentSetKeepsAllUpdates 钉住并发 Set 的安全性(review p1): // 落盘必须与内存改动**同一把锁**、且用**新建的临时文件**,否则两个写者会撞同一个 // tmp 名把 JSON 写坏(⇒ load 退回默认「甲」、主人刚说的「少说点」白说且无提示), // 或者 rename 的完成顺序与新快照的先后不一致,丢掉后发生的更新。 // // 修前必红(原实现:固定 `path+".tmp"` + mu.Unlock() 之后落盘),断言: // ① 每一轮结束后文件都是**合法 JSON**; // ② 本轮写过的**每一个**键都能在最终文件里读到(不丢更新)。 func TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "config", "owner_lane.json") s := NewOwnerLaneStore(path, nil) const ( rounds = 24 writers = 24 ) for round := 0; round < rounds; round++ { var wg sync.WaitGroup for i := 0; i < writers; i++ { key := fmt.Sprintf("g-%d-%d", round, i) wg.Add(1) go func(i int) { defer wg.Done() // 写者的返回值故意不看:修前它会因 tmp 被别的写者 rename 走而报错, // 但那正是本用例要靠**文件内容**抓住的失败。 _ = s.Set("qq-obv11", key, OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: i}) }(i) } wg.Wait() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("round %d: 读回 owner_lane.json 失败:%v", round, err) } var f OwnerLaneFile if err := json.Unmarshal(raw, &f); err != nil { t.Fatalf("round %d: 并发落盘把配置写坏了(文件不是合法 JSON):%v\n内容=%q", round, err, raw) } for i := 0; i < writers; i++ { key := OwnerLaneGroupKey("qq-obv11", fmt.Sprintf("g-%d-%d", round, i)) if _, ok := f.Groups[key]; !ok { t.Fatalf("round %d: 丢更新 —— 键 %s 不在最终文件里(并发的某次 Set 没落盘)", round, key) } } } }