Files
knowledge-governance/internal/httpapi/library_handoff_queue_test.go
姚章浩 8a68ed1819 fix(library): 交接所有权 CAS、回传游标跨轮与快照原产地
父审查确认四项是原 finding 未闭环,本包按真实调用链修完(先 RED 再修)。

R4a 正常流程必现阻断:API 先入 handoff 队列作业并关联,交接却只接受"未关联",
每次正常发布都被自己的所有权检查挡住。新增 governance.LibraryHandoffQueueJobID
(jobs.id 为 varchar(36),原实现拼后缀超长,插入即失败);EnqueueAndAttachLibraryJob
增加"声明的前驱队列作业号",只允许当前无人持有或正是该 handoff 时替换,任意替换拒绝。
测试从 API 真实前驱(已关联)开始,覆盖正常交接、错误前驱拒绝、交接后重放、
以及 release 与 handoff 两条队列行都存在。

R4b 迟到 handoff 污染已交接作业:失败出口(含 Push 错误)改为所有权 CAS
(FailLibraryHandoffIfOwner),只在业务作业仍由本次 handoff 持有时写 state/stage/recovery;
已交接/已终态零修改。所有权判定改为只看 library_jobs.queue_job_id(阶段行是否存在
与所有权无关,原实现会误判)。阶段写入也带所有权。用测试屏障确定性地复现"失败出口
到来时所有权已切给 release",验证 release running 与 succeeded 两种状态下零修改。

R2 回传游标每轮重置:改为跨轮保留(单轮仍 20 页预算),读到不足一页才回到起点;
新增 pullCursor 与跨轮覆盖测试(2100 条引用、每轮 5 页仍全部覆盖,不重复不漏取)。

R3 原产地误判:封存成功后会再登记 production_effective(同一快照),原实现把
sealed+target 判成冲突,正常再发布/历史恢复被误拒。现在原产地只由捕获事件决定
(test_sealed=源 / production_prebackup=目标),effective/recovery_verified 只是引用;
二者冲突或都没有捕获事件仍拒绝。测试覆盖正常成功链、真冲突、无捕获事件三种。

验证:隔离 fixture PostgreSQL 实跑 cmd/worker 全包通过(含上述四条真实调用链);
libraryhandoff/librarysnapshot/httpapi/governance/platform-postgres/libraryctl 全绿;
完整 ci_checks 见提交时日志。未部署、未推送、未构建镜像;真实跨主机 SSH 链路仍未验证。
2026-10-30 19:22:48 +08:00

53 lines
1.8 KiB
Go

package httpapi
import (
"encoding/json"
"testing"
"knowledge-governance/internal/governance"
)
// 搬运接线时,发布作业必须"先搬运":队列类型换成 handoff,释放作业的 ID 仍是基础 ID。
func TestLibraryQueueRequestGatesReleaseOnHandoff(t *testing.T) {
jobID := "libsync-internal-1"
snapshot := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
id, queueType, payload, err := libraryQueueRequest(true, jobID, "internal", snapshot, librarySyncJobType)
if err != nil {
t.Fatal(err)
}
if queueType != libraryHandoffJobType {
t.Fatalf("queue type = %q, want %q", queueType, libraryHandoffJobType)
}
if id != governance.LibraryHandoffQueueJobID(jobID) {
t.Fatalf("handoff queue id = %q", id)
}
// jobs.id 是 varchar(36):交接用的队列作业号必须能真正插进去(曾因拼后缀超长而必失败)。
if len(id) > 36 {
t.Fatalf("handoff queue id 长度 %d 超过 jobs.id 上限 36", len(id))
}
var decoded map[string]string
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
}
if decoded["next_type"] != librarySyncJobType || decoded["target_snapshot_id"] != snapshot {
t.Fatalf("handoff payload = %#v", decoded)
}
// 未接线:行为与今天完全一致(直接投递发布作业,ID 是基础 ID)。
id, queueType, payload, err = libraryQueueRequest(false, jobID, "internal", snapshot, libraryRestoreJobType)
if err != nil {
t.Fatal(err)
}
if queueType != libraryRestoreJobType || id != governance.QueueJobIDFor(jobID) {
t.Fatalf("unconfigured queue request = %q %q", queueType, id)
}
decoded = map[string]string{}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
}
if _, present := decoded["next_type"]; present {
t.Fatal("release payload must not carry next_type when handoff is off")
}
}