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()) } }