Files
knowledge-governance/internal/workflows/testweknora/poll_test.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(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 干净
2026-09-30 10:54:06 +08:00

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
}