47eb23cf68
页面上的"可发起封存"原来只判 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 带原因;重启后恢复。
160 lines
6.1 KiB
Go
160 lines
6.1 KiB
Go
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)
|
||
}
|
||
}
|