dcdc8d1ddd
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环): - 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口) - 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入 parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截 - 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过): * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id) * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配 * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数 * 回滚 job payload 支持 prior_execution_id(strict 解码) - 三通道发布+回滚演练实测:publish 4/4 verified,检索命中, rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false - 前端 A·Data-console 风格全站统一,120 用例通过 - 门禁:go vet / go test 全包通过,gofmt 干净
189 lines
8.0 KiB
Go
189 lines
8.0 KiB
Go
package testweknora
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
"knowledge-governance/internal/jobs"
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/platform/weknora"
|
|
)
|
|
|
|
func TestPollHandlerSchedulesNextSequenceForRemoteProgress(t *testing.T) {
|
|
remoteUpdated := time.Date(2026, time.September, 4, 3, 39, 0, 0, time.UTC)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/knowledge/knowledge-1" {
|
|
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-1","knowledge_base_id":"kb-1","parse_status":"finalizing","updated_at":"2026-09-04T03:39:00Z"}}`))
|
|
}))
|
|
defer server.Close()
|
|
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, time.September, 4, 3, 40, 0, 0, time.UTC)
|
|
lastProgress := now.Add(-time.Minute)
|
|
store := &pollStoreStub{publication: postgresstore.PublicationForPoll{
|
|
ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1",
|
|
ProcessingState: governance.ProcessingProcessing, PollSequence: 2,
|
|
LastRemoteStatus: "processing", LastProgressAt: &lastProgress,
|
|
}}
|
|
handler, err := NewPollHandler(PollHandlerConfig{
|
|
Store: store, Client: client, PollDelay: 15 * time.Second,
|
|
StallAfter: 30 * time.Minute, Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1","sequence":2}`)}); err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if store.recordedState != governance.ProcessingFinalizing || store.recordedRemoteUpdated == nil || !store.recordedRemoteUpdated.Equal(remoteUpdated) || !store.nextPollAt.Equal(now.Add(15*time.Second)) {
|
|
t.Fatalf("store = %#v", store)
|
|
}
|
|
}
|
|
|
|
func TestPollHandlerCompletesAndSchedulesValidationWithoutNextPoll(t *testing.T) {
|
|
completedAt := time.Date(2026, time.September, 4, 3, 45, 0, 0, time.UTC)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/knowledge/knowledge-1" {
|
|
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-1","knowledge_base_id":"kb-1","parse_status":"completed","updated_at":"2026-09-04T03:45:00Z"}}`))
|
|
}))
|
|
defer server.Close()
|
|
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, time.September, 4, 3, 46, 0, 0, time.UTC)
|
|
lastProgress := now.Add(-time.Minute)
|
|
store := &pollStoreStub{publication: postgresstore.PublicationForPoll{
|
|
ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1",
|
|
ProcessingState: governance.ProcessingProcessing, PollSequence: 3,
|
|
LastRemoteStatus: "processing", LastProgressAt: &lastProgress,
|
|
}}
|
|
handler, err := NewPollHandler(PollHandlerConfig{
|
|
Store: store, Client: client, PollDelay: 15 * time.Second,
|
|
StallAfter: 30 * time.Minute, Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1","sequence":3}`)}); err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if !store.completedValidated || store.recordedState != "" || store.recordedRemoteUpdated == nil || !store.recordedRemoteUpdated.Equal(completedAt) {
|
|
t.Fatalf("store = %#v", store)
|
|
}
|
|
}
|
|
|
|
func TestPollHandlerMarksStalledWhenNoProgressExceedsThreshold(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/knowledge/knowledge-1" {
|
|
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-1","knowledge_base_id":"kb-1","parse_status":"finalizing","updated_at":"2026-09-04T03:10:00Z"}}`))
|
|
}))
|
|
defer server.Close()
|
|
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, time.September, 4, 3, 50, 0, 0, time.UTC)
|
|
stalledSince := now.Add(-45 * time.Minute)
|
|
store := &pollStoreStub{publication: postgresstore.PublicationForPoll{
|
|
ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1",
|
|
ProcessingState: governance.ProcessingProcessing, PollSequence: 4,
|
|
LastRemoteStatus: "processing", LastProgressAt: &stalledSince,
|
|
}}
|
|
handler, err := NewPollHandler(PollHandlerConfig{
|
|
Store: store, Client: client, PollDelay: 15 * time.Second,
|
|
StallAfter: 30 * time.Minute, Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1","sequence":4}`)}); err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if !store.stalledMarked || store.recordedState != "" || !store.stalledCutoff.Equal(now.Add(-30*time.Minute)) {
|
|
t.Fatalf("store = %#v", store)
|
|
}
|
|
}
|
|
|
|
func TestPollHandlerRejectsStaleSequenceWithoutPollingRemote(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("stale sequence must not poll the remote knowledge endpoint")
|
|
}))
|
|
defer server.Close()
|
|
client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-api-key", Timeout: time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, time.September, 4, 4, 0, 0, 0, time.UTC)
|
|
store := &pollStoreStub{publication: postgresstore.PublicationForPoll{
|
|
ID: "publication-1", TestKBID: "kb-1", KnowledgeID: "knowledge-1",
|
|
ProcessingState: governance.ProcessingProcessing, PollSequence: 5,
|
|
}}
|
|
handler, err := NewPollHandler(PollHandlerConfig{
|
|
Store: store, Client: client, PollDelay: 15 * time.Second,
|
|
StallAfter: 30 * time.Minute, Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1","sequence":2}`)}); !errors.Is(err, ErrPollStateConflict) {
|
|
t.Fatalf("Handle() error = %v, want state conflict", err)
|
|
}
|
|
if store.recordedState != "" || store.stalledMarked || store.completedValidated {
|
|
t.Fatalf("store mutated for stale sequence: %#v", store)
|
|
}
|
|
}
|
|
|
|
type pollStoreStub struct {
|
|
publication postgresstore.PublicationForPoll
|
|
recordedState governance.ProcessingState
|
|
recordedRemoteUpdated *time.Time
|
|
nextPollAt time.Time
|
|
completedValidated bool
|
|
stalledMarked bool
|
|
stalledCutoff time.Time
|
|
}
|
|
|
|
func (store *pollStoreStub) LoadPublicationForPoll(context.Context, string) (postgresstore.PublicationForPoll, error) {
|
|
return store.publication, nil
|
|
}
|
|
|
|
func (store *pollStoreStub) RecordRemoteStatusAndScheduleNextPoll(_ context.Context, _ string, _ int, state governance.ProcessingState, remoteUpdated *time.Time, _ time.Time, nextAt time.Time) (int, error) {
|
|
store.recordedState = state
|
|
store.recordedRemoteUpdated = remoteUpdated
|
|
store.nextPollAt = nextAt
|
|
return store.publication.PollSequence + 1, nil
|
|
}
|
|
|
|
func (store *pollStoreStub) RecordCompletedAndScheduleValidation(_ context.Context, _ string, _ int, remoteUpdated *time.Time, _ time.Time) error {
|
|
store.completedValidated = true
|
|
store.recordedRemoteUpdated = remoteUpdated
|
|
return nil
|
|
}
|
|
|
|
func (store *pollStoreStub) RecordProcessingFailure(context.Context, string, int, string, time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (store *pollStoreStub) MarkStalled(_ context.Context, _ string, _ int, cutoff, _ time.Time) error {
|
|
store.stalledMarked = true
|
|
store.stalledCutoff = cutoff
|
|
return nil
|
|
}
|