Files
knowledge-governance/internal/httpapi/library_capture_test.go
T
姚章浩 f2d6f3f646
build / build (push) Successful in 1m17s
fix(httpapi): 封存队列作业 id 按 jobs.id 列宽拼短
真实请求 POST /api/v1/library/captures 一律 500:拼出来的
"library-capture-<scope>-<nano>" 是 45 个字符,而 jobs.id 是 varchar(36),
PG 直接拒绝(value too long)。此前页面一直停在"未接线",这条路径从没被
真实请求覆盖过,所以只有接上 agent 之后才暴露。

改为 libcap-<scope>-<nano>(35 字符),并用测试锁住列宽与形状。

试过:go test ./internal/httpapi -run LibraryCapture 全绿;修复前用真实
DATABASE_URL 复现 enqueue error: value too long for type character varying(36)。
2026-10-29 06:05:16 +08:00

181 lines
7.0 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 (
"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
}
// An unwired agent must refuse clearly instead of queueing a job nobody can run.
func TestLibraryCaptureRefusedWhenAgentUnwired(t *testing.T) {
store := &fakeJobStore{}
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(`{"scope":"internal"}`), Config{Store: store})
if recorder.Code != http.StatusServiceUnavailable {
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_unwired" || body["reason"] == "" {
t.Fatalf("unexpected body %v", body)
}
if store.enqueued.Type != "" {
t.Fatal("unwired agent still enqueued a job")
}
}
func TestLibraryCaptureEnqueuesFixedJobShape(t *testing.T) {
store := &fakeJobStore{enqueueEchoRequestedID: true}
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(`{"scope":"external","job":"lib-external-review"}`),
Config{Store: store, LibraryAgentSocket: "/run/library-agent/agent.sock"})
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), Config{Store: store, LibraryAgentSocket: "/tmp/agent.sock"})
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+`"}`),
Config{Store: store, LibraryAgentSocket: "/tmp/agent.sock"})
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"}`),
Config{Store: store, LibraryAgentSocket: "/run/library-agent/agent.sock"})
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"}`),
Config{Store: store, LibraryAgentSocket: "/run/library-agent/agent.sock"})
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)
}
}