Files
knowledge-governance/internal/httpapi/library_capture_test.go
姚章浩 47eb23cf68 feat(library): 封存执行器可用性与还原条件来自真实观测
页面上的"可发起封存"原来只判 LIBRARY_AGENT_SOCKET 非空:正式栈的 API 根本没挂
那个 socket,而 agent 曾顶着正式单元名冻结候选实例——配置非空、页面可点、真发起
却封错库。

现在:agent 新增 health 动作(不占封存操作锁、不跑子进程,只回答身份/降级/忙),
worker 每 30 秒探测并写入迁移 074 的 library_executor_status;overview、快照列表与
POST /library/captures 共用同一份判定(过期、不可达、实例不符、降级、正忙都拒绝并
给原因)。封存结果由 agent 附上自报身份,worker 在停写入者之前再探测一次、执行后
用结果复核,不一致就不登记测试封存——心跳只能证明"刚才"。

期望身份用 LIBRARY_SOURCE_*(封存源),不是 LIBRARY_TARGET_*(同步目标),否则
正确的执行器会被判成不匹配。另加 GET /library/restore-availability:恢复能否执行由
双闸、该快照的恢复授权与实例维护状态决定,页面不再写死 disabled。

真跑(候选隔离栈,两处 socket 隔离不变):overview 返回 executor
{kgmaint-weknora/kgmaint-app};停 agent 后转不可达并 503 带原因;重启后恢复。
2026-10-29 13:01:37 +08:00

160 lines
6.1 KiB
Go
Raw Permalink 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 (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/jobs"
)
func captureRequest(body string) *http.Request {
request := httptest.NewRequest(http.MethodPost, "/api/v1/library/captures", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
return request
}
func TestLibraryCaptureEnqueuesFixedJobShape(t *testing.T) {
store := &fakeJobStore{enqueueEchoRequestedID: true}
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(`{"scope":"external","job":"lib-external-review"}`),
captureReadyConfig(store))
if recorder.Code != http.StatusAccepted {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
enqueued := store.enqueued
if enqueued.Type != libraryCaptureJobType {
t.Fatalf("job type %q", enqueued.Type)
}
if enqueued.IdempotencyKey != "library.capture:lib-external-review" {
t.Fatalf("idempotency key %q", enqueued.IdempotencyKey)
}
var payload map[string]string
if err := json.Unmarshal(enqueued.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["scope"] != "external" || payload["capture_job"] != "lib-external-review" {
t.Fatalf("payload %v", payload)
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["capture_job"] != "lib-external-review" || body["scope"] != "external" {
t.Fatalf("response %v", body)
}
}
func TestLibraryCaptureRejectsInvalidRequest(t *testing.T) {
for _, body := range []string{
`{"scope":"production"}`,
`{"scope":"internal","job":"bad job; rm -rf /"}`,
`not json`,
} {
store := &fakeJobStore{}
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(body), captureReadyConfig(store))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("body %q status=%d", body, recorder.Code)
}
if store.enqueued.Type != "" {
t.Fatalf("invalid request %q still enqueued", body)
}
}
}
func TestLibraryCaptureQueueIDFitsTheColumn(t *testing.T) {
// jobs.id 是 varchar(36):拼长的 id 会在真实 PG 上 500(value too long)。
// 页面长期停在"未接线",这条从没被真实请求覆盖过,所以用测试锁住列宽。
for _, scope := range []string{"internal", "external"} {
store := &fakeJobStore{enqueueEchoRequestedID: true}
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(`{"scope":"`+scope+`"}`), captureReadyConfig(store))
if recorder.Code != http.StatusAccepted {
t.Fatalf("%s status=%d body=%s", scope, recorder.Code, recorder.Body.String())
}
enqueued := store.enqueued
if len(enqueued.ID) > 36 {
t.Fatalf("%s 队列作业 id 超长(%d):%q", scope, len(enqueued.ID), enqueued.ID)
}
if !strings.HasPrefix(enqueued.ID, "libcap-"+scope+"-") {
t.Fatalf("%s 队列作业 id 形状变了:%q", scope, enqueued.ID)
}
}
}
func TestLibraryCaptureJobsAsksStoreForItsOwnType(t *testing.T) {
// The global queue is busy with unrelated traffic; the capture job must still
// be visible because the query is type-scoped, not filtered after the fact.
store := &fakeJobStore{
listResult: []jobs.Job{{ID: "busy", Type: "artifact.fetch", State: "running"}},
listByTypeResult: []jobs.Job{
{ID: "a", Type: libraryCaptureJobType, State: "completed",
Payload: json.RawMessage(`{"scope":"internal","capture_job":"lib-internal-1","requested_by":"yaozhanghao"}`),
CreatedAt: time.Date(2026, 10, 28, 3, 0, 0, 0, time.UTC)},
},
}
recorder := httptest.NewRecorder()
libraryCaptureJobs(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/library/captures", nil), Config{Store: store})
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d", recorder.Code)
}
if store.listType != libraryCaptureJobType {
t.Fatalf("store queried with type %q", store.listType)
}
var body struct {
Available bool `json:"available"`
Jobs []map[string]any `json:"jobs"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !body.Available || len(body.Jobs) != 1 || body.Jobs[0]["capture_job"] != "lib-internal-1" {
t.Fatalf("unexpected body %+v", body)
}
}
// Reusing a capture job id with another scope must be refused, and the response
// must report the scope that was actually stored.
func TestLibraryCaptureRejectsScopeConflictForReusedJob(t *testing.T) {
store := &fakeJobStore{enqueueResult: &jobs.Job{ID: "existing-job", Type: libraryCaptureJobType,
State: jobs.StateCompleted,
Payload: json.RawMessage(`{"scope":"internal","capture_job":"lib-shared","requested_by":"yaozhanghao"}`)}}
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(`{"scope":"external","job":"lib-shared"}`),
captureReadyConfig(store))
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["code"] != "library_capture_scope_conflict" || body["existing_scope"] != "internal" || body["requested_scope"] != "external" {
t.Fatalf("unexpected conflict body %v", body)
}
}
// A reused job id with an identical request is answered from the stored row.
func TestLibraryCaptureReplayReportsStoredJob(t *testing.T) {
store := &fakeJobStore{enqueueResult: &jobs.Job{ID: "existing-job", Type: libraryCaptureJobType,
State: jobs.StateCompleted,
Payload: json.RawMessage(`{"scope":"internal","capture_job":"lib-shared","requested_by":"yaozhanghao"}`)}}
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(`{"scope":"internal","job":"lib-shared"}`),
captureReadyConfig(store))
if recorder.Code != http.StatusAccepted {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["scope"] != "internal" || body["capture_job"] != "lib-shared" || body["reused"] != true || body["job_id"] != "existing-job" {
t.Fatalf("unexpected replay body %v", body)
}
}