Files
knowledge-governance/internal/httpapi/library_intent_test.go
T
姚章浩 afe9affc2a
build / build (push) Successful in 1m27s
style: gofmt 新增的三个文件
为什么:远端 CI 的格式门在推送后判红(run 258,job build,format=failure),
本地 `make verify` 那次跑的是"拉取前"的文件集,漏掉了随后新增/改写的三个文件。
失败项:internal/httpapi/library_intent_test.go、internal/jobs/lease_loss_test.go、
internal/platform/postgres/library_protection.go。

怎么验:`gofmt -l internal/ cmd/` 输出为空;`python3 scripts/check_format.py` rc=0
(Go:✅,前端:✅)。只改空白,不改逻辑。
2026-10-30 16:16:45 +08:00

126 lines
6.2 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 httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
)
// F01:同一个目标快照的**新意图**必须真的执行,不能被"这个快照曾经成功过"吞掉。
//
// 现场形状:同步 B 成功 → 恢复 A → 再次同步 B。第三次请求是一次合法意图,
// 旧实现返回第一次的成功作业(202, 不再并入队),用户要的状态没实现。
//
// 判据:换 operation_key ⇒ 新 operation(ID 不同、作业幂等键不同);
// 同一个 operation_key ⇒ 同一 operation(重放)。
func TestLibrarySyncNewIntentReExecutesInsteadOfReusingHistory(t *testing.T) {
target := "snap-1"
digest, err := librarySyncParamsDigest("internal", target)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
newStore := func() *fakeLibraryStore {
store := &fakeLibraryStore{
current: &governance.LibraryVersion{ID: "ver-prod", Scope: "internal",
Kind: governance.LibraryVersionProductionEffective, SnapshotID: "prod-snap",
VersionLabel: "prod-2026.10", CreatedAt: now.Add(-72 * time.Hour)},
}
store.enqueueEchoRequestedID = true
store.grant = &governance.LibraryReleaseAuthorization{ID: "auth-1", Scope: "internal", SnapshotID: target,
ParamsDigest: digest, AuthorizedBy: "admin", ExpiresAt: now.Add(30 * time.Minute), CreatedAt: now}
return store
}
config := func(store *fakeLibraryStore) Config {
return Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
LibraryProductionDatabaseURL: "postgres://prod.invalid/db", LibraryProductionInternalKB: "kb-int",
LibraryCapacityBudgetBytes: 1 << 40}
}
post := func(store *fakeLibraryStore, body string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
librarySyncEnqueue(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs",
strings.NewReader(body)), config(store))
return recorder
}
withKey := `{"scope":"internal","target_snapshot_id":"` + target + `","operation_key":"%s"}`
// 第一次意图:建作业。
first := newStore()
if recorder := post(first, strings.Replace(withKey, "%s", "intent-1", 1)); recorder.Code != http.StatusAccepted {
t.Fatalf("首次发布会话 status=%d body=%s", recorder.Code, recorder.Body.String())
}
firstOperation := first.lastOperation.ID
if firstOperation == "" || first.lastJob.OperationID != firstOperation {
t.Fatalf("作业必须挂在意图上:job.operation=%q intent=%q", first.lastJob.OperationID, firstOperation)
}
// 同一次意图重放(双击/超时重发):同一个 operation,不产生第二条意图。
replay := newStore()
if recorder := post(replay, strings.Replace(withKey, "%s", "intent-1", 1)); recorder.Code != http.StatusAccepted {
t.Fatalf("重放 status=%d body=%s", recorder.Code, recorder.Body.String())
}
if replay.lastOperation.ID != firstOperation {
t.Fatalf("同一意图键得到了不同 operation:%q vs %q", replay.lastOperation.ID, firstOperation)
}
// 新意图(同一快照、不同键):必须是新 operation ⇒ 新作业幂等键 ⇒ 真的执行。
second := newStore()
if recorder := post(second, strings.Replace(withKey, "%s", "intent-2", 1)); recorder.Code != http.StatusAccepted {
t.Fatalf("新意图 status=%d body=%s", recorder.Code, recorder.Body.String())
}
if second.lastOperation.ID == firstOperation {
t.Fatal("同一快照的新意图必须得到新 operation:否则历史成功会吞掉本次请求(F01)")
}
if second.lastJob.IdempotencyKey == first.lastJob.IdempotencyKey {
t.Fatal("新意图的作业幂等键与历史作业相同:CreateLibraryJob 会返回旧作业,什么也不执行")
}
if second.createN != 1 {
t.Fatalf("新意图必须建作业并执行,created=%d", second.createN)
}
}
// 同一个意图键配上不同快照 = 冲突,不能悄悄共享谱系。
func TestLibrarySyncIntentKeyReusedWithAnotherSnapshotConflicts(t *testing.T) {
target := "snap-1"
digest, _ := librarySyncParamsDigest("internal", target)
now := time.Now().UTC()
store := &fakeLibraryStore{current: &governance.LibraryVersion{ID: "ver-prod", Scope: "internal",
Kind: governance.LibraryVersionProductionEffective, SnapshotID: "prod-snap", CreatedAt: now.Add(-time.Hour)}}
store.grant = &governance.LibraryReleaseAuthorization{ID: "auth-1", Scope: "internal", SnapshotID: target,
ParamsDigest: digest, AuthorizedBy: "admin", ExpiresAt: now.Add(time.Hour), CreatedAt: now}
// 预先登记一条"同键但不同快照"的意图。
if _, _, err := store.EnsureLibraryOperation(nil, governance.LibraryOperation{
ID: "libop-x", Scope: "internal", Kind: governance.LibraryJobSync, TargetSnapshotID: "other-snap",
RequestDigest: "different", OperationKey: target + "#intent-1", CreatedAt: now}); err != nil {
t.Fatal(err)
}
config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true,
LibraryProductionDatabaseURL: "postgres://prod.invalid/db", LibraryProductionInternalKB: "kb-int",
LibraryCapacityBudgetBytes: 1 << 40}
body := `{"scope":"internal","target_snapshot_id":"` + target + `","operation_key":"intent-1"}`
recorder := httptest.NewRecorder()
librarySyncEnqueue(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/library/syncs", strings.NewReader(body)), config)
if recorder.Code != http.StatusConflict {
t.Fatalf("同键不同内容必须 409,status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
// F14:通用 queue 入口不许创建整库保留类型——那里的角色检查替代不了发布准入。
func TestCreateJobRejectsReservedLibraryTypes(t *testing.T) {
store := &fakeJobStore{}
config := Config{Store: store}
body := `{"type":"library.baseline","payload":{"scope":"internal"},"idempotency_key":"k-1"}`
recorder := httptest.NewRecorder()
createJob(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)), config)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("保留类型必须被通用入口拒绝:status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "发布管理专用入口") {
t.Fatalf("拒绝原因必须指向专用入口:%s", recorder.Body.String())
}
}