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 干净
254 lines
11 KiB
Go
254 lines
11 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
func TestPublicationOrchestrationIsAtomicAndRejectsStaleTransitions(t *testing.T) {
|
|
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
if err := ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sha := strings.Repeat("a", 64)
|
|
if _, err = pool.Exec(ctx, `INSERT INTO source_connections (id, source_type, name) VALUES ('connection-1', 'pp', 'test')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = pool.Exec(ctx, `INSERT INTO source_items (
|
|
id, source_connection_id, stable_external_id, canonical_uri_hash,
|
|
title, item_type, revision, visibility, visibility_origin, source_change
|
|
) VALUES (
|
|
'item-1', 'connection-1', 'external-1', $1,
|
|
'Safe title', 'document', $2, 'external', 'pp_public_tag', 'added'
|
|
)`, sha, sha); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = pool.Exec(ctx, `INSERT INTO content_objects (
|
|
id, sha256, size_bytes, mime, validated_format, local_spool_ref, content_state
|
|
) VALUES ('content-1', $1, 24, 'text/markdown', 'md', 'objects/artifact.md', 'verified')`, sha); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = pool.Exec(ctx, `INSERT INTO source_item_versions (
|
|
id, source_item_id, revision, content_object_id, content_state
|
|
) VALUES ('version-1', 'item-1', $1, 'content-1', 'verified')`, sha); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = pool.Exec(ctx, `INSERT INTO test_publications (
|
|
id, source_item_version_id, test_kb_id, ingest_state, processing_state, retrieval_state
|
|
) VALUES ('publication-1', 'version-1', 'kb-1', 'pending', 'pending', 'unpublished')`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store := NewStore(pool)
|
|
now := time.Date(2026, time.September, 4, 2, 20, 0, 0, time.UTC)
|
|
if _, err := store.LoadPublicationForUpload(ctx, "publication-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.MarkUploading(ctx, "publication-1", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sequence, err := store.RecordUploadAndSchedulePoll(ctx, "publication-1", "knowledge-1", now, now.Add(10*time.Second)); err != nil || sequence != 1 {
|
|
t.Fatalf("record upload = %d, %v", sequence, err)
|
|
}
|
|
if sequence, err := store.RecordUploadAndSchedulePoll(ctx, "publication-1", "knowledge-1", now, now.Add(10*time.Second)); err != nil || sequence != 1 {
|
|
t.Fatalf("replay upload = %d, %v", sequence, err)
|
|
}
|
|
if _, err := store.RecordUploadAndSchedulePoll(ctx, "publication-1", "knowledge-conflict", now, now.Add(10*time.Second)); !errors.Is(err, ErrPublicationStateConflict) {
|
|
t.Fatalf("conflicting knowledge error = %v", err)
|
|
}
|
|
|
|
remoteUpdatedAt := now.Add(5 * time.Second)
|
|
if sequence, err := store.RecordRemoteStatusAndScheduleNextPoll(ctx, "publication-1", 1, governance.ProcessingProcessing, &remoteUpdatedAt, now.Add(10*time.Second), now.Add(40*time.Second)); err != nil || sequence != 2 {
|
|
t.Fatalf("record processing = %d, %v", sequence, err)
|
|
}
|
|
if _, err := store.RecordRemoteStatusAndScheduleNextPoll(ctx, "publication-1", 1, governance.ProcessingProcessing, &remoteUpdatedAt, now.Add(11*time.Second), now.Add(41*time.Second)); !errors.Is(err, ErrPublicationStateConflict) {
|
|
t.Fatalf("stale poll error = %v", err)
|
|
}
|
|
if err := store.RecordCompletedAndScheduleValidation(ctx, "publication-1", 2, &remoteUpdatedAt, now.Add(20*time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.RecordRemoteStatusAndScheduleNextPoll(ctx, "publication-1", 2, governance.ProcessingProcessing, &remoteUpdatedAt, now.Add(21*time.Second), now.Add(51*time.Second)); !errors.Is(err, ErrPublicationStateConflict) {
|
|
t.Fatalf("completed regression error = %v", err)
|
|
}
|
|
|
|
var ingestState, processingState, knowledgeID string
|
|
var sequence, jobCount int
|
|
if err := pool.QueryRow(ctx, `SELECT ingest_state, processing_state, knowledge_id, poll_sequence FROM test_publications WHERE id = 'publication-1'`).Scan(&ingestState, &processingState, &knowledgeID, &sequence); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs`).Scan(&jobCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ingestState != "uploaded" || processingState != "completed" || knowledgeID != "knowledge-1" || sequence != 2 || jobCount != 3 {
|
|
t.Fatalf("final state = %s/%s/%s sequence=%d jobs=%d", ingestState, processingState, knowledgeID, sequence, jobCount)
|
|
}
|
|
if err := store.AppendAcceptanceResult(ctx, governance.AcceptanceResult{
|
|
ID: "acceptance-1", PublicationID: "publication-1", RunID: "run-1",
|
|
CheckType: "chunks_present", State: governance.AcceptancePassed,
|
|
PolicyVersion: "structural-v1", EvidenceSummary: json.RawMessage(`{"chunk_total":2}`), ObservedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("append acceptance result: %v", err)
|
|
}
|
|
var acceptanceCount int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM acceptance_results WHERE publication_id = 'publication-1'`).Scan(&acceptanceCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if acceptanceCount != 1 {
|
|
t.Fatalf("acceptance count = %d", acceptanceCount)
|
|
}
|
|
|
|
validation, err := store.LoadPublicationForValidation(ctx, "publication-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if validation.ID != "publication-1" || validation.TestKBID != "kb-1" || validation.KnowledgeID != "knowledge-1" {
|
|
t.Fatalf("validation context = %#v", validation)
|
|
}
|
|
if err := store.AppendAcceptanceResult(ctx, governance.AcceptanceResult{
|
|
ID: "acceptance-1", PublicationID: "publication-1", RunID: "run-1",
|
|
CheckType: "chunks_present", State: governance.AcceptanceFailed,
|
|
PolicyVersion: "structural-v1", EvidenceSummary: json.RawMessage(`{"chunk_total":0}`), ObservedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("idempotent append: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM acceptance_results WHERE publication_id = 'publication-1'`).Scan(&acceptanceCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if acceptanceCount != 1 {
|
|
t.Fatalf("acceptance count after idempotent append = %d", acceptanceCount)
|
|
}
|
|
if _, err := store.LoadPublicationForValidation(ctx, "missing"); !errors.Is(err, ErrPublicationStateConflict) {
|
|
t.Fatalf("non-completed validation error = %v", err)
|
|
}
|
|
legacy, listErr := store.ListLegacyVerificationCandidates(ctx)
|
|
if listErr != nil {
|
|
t.Fatal(listErr)
|
|
}
|
|
if len(legacy) != 1 || legacy[0].ID != "publication-1" || legacy[0].KnowledgeID != "knowledge-1" || legacy[0].TestKBID != "kb-1" {
|
|
t.Fatalf("legacy candidates = %#v", legacy)
|
|
}
|
|
|
|
page, listErr := store.ListTestPublications(ctx, governance.TestPublicationFilter{Limit: 10})
|
|
if listErr != nil {
|
|
t.Fatal(listErr)
|
|
}
|
|
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != "publication-1" || page.Items[0].Title == "" || page.Items[0].CanRetry {
|
|
t.Fatalf("test publication page = %#v", page)
|
|
}
|
|
|
|
records, accErr := store.ListAcceptanceResults(ctx, "publication-1")
|
|
if accErr != nil {
|
|
t.Fatal(accErr)
|
|
}
|
|
if len(records) != 1 || records[0].CheckType != "chunks_present" {
|
|
t.Fatalf("acceptance records = %#v", records)
|
|
}
|
|
var evidence map[string]any
|
|
if err := json.Unmarshal(records[0].EvidenceSummary, &evidence); err != nil || evidence["chunk_total"] != float64(2) {
|
|
t.Fatalf("evidence = %s, %v", records[0].EvidenceSummary, err)
|
|
}
|
|
|
|
if err := store.RetryPublication(ctx, "publication-1", now); !errors.Is(err, ErrPublicationStateConflict) {
|
|
t.Fatalf("completed retry error = %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `UPDATE test_publications SET processing_state = 'failed', last_error = 'remote_parse_failed' WHERE id = 'publication-1'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.RetryPublication(ctx, "publication-1", now); err != nil {
|
|
t.Fatalf("retry failed publication: %v", err)
|
|
}
|
|
var resetProcessing, resetIngest string
|
|
if err := pool.QueryRow(ctx, `SELECT processing_state, ingest_state FROM test_publications WHERE id = 'publication-1'`).Scan(&resetProcessing, &resetIngest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resetProcessing != "pending" || resetIngest != "pending" {
|
|
t.Fatalf("retry reset = %s/%s", resetProcessing, resetIngest)
|
|
}
|
|
var uploadJobs int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type = 'test.upload'`).Scan(&uploadJobs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if uploadJobs != 1 {
|
|
t.Fatalf("upload jobs after retry = %d", uploadJobs)
|
|
}
|
|
}
|
|
|
|
func TestSeedManualPublicationIsAtomicAndIdempotent(t *testing.T) {
|
|
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
if err := ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatalf("ApplyMigrations() error = %v", err)
|
|
}
|
|
store := NewStore(pool)
|
|
request := ManualPublicationSeedRequest{
|
|
TestKBID: "kb-manual-e2e",
|
|
Title: "Manual E2E fixture",
|
|
SHA256: strings.Repeat("b", 64),
|
|
SizeBytes: 128,
|
|
MIME: "text/markdown",
|
|
ValidatedFormat: "markdown",
|
|
LocalSpoolRef: "manual/fixture.md",
|
|
Now: time.Date(2026, 9, 5, 5, 30, 0, 0, time.UTC),
|
|
}
|
|
first, err := store.SeedManualPublication(ctx, request)
|
|
if err != nil {
|
|
t.Fatalf("first SeedManualPublication() error = %v", err)
|
|
}
|
|
second, err := store.SeedManualPublication(ctx, request)
|
|
if err != nil {
|
|
t.Fatalf("second SeedManualPublication() error = %v", err)
|
|
}
|
|
if first != second {
|
|
t.Fatalf("seed results differ: first=%#v second=%#v", first, second)
|
|
}
|
|
var sourceType string
|
|
var connectionCount, itemCount, contentCount, versionCount, publicationCount, jobCount int
|
|
if err := pool.QueryRow(ctx, `
|
|
SELECT
|
|
(SELECT source_type FROM source_connections WHERE id = 'conn-manual-local'),
|
|
(SELECT COUNT(*) FROM source_connections WHERE id = 'conn-manual-local'),
|
|
(SELECT COUNT(*) FROM source_items WHERE source_connection_id = 'conn-manual-local'),
|
|
(SELECT COUNT(*) FROM content_objects WHERE sha256 = $1),
|
|
(SELECT COUNT(*) FROM source_item_versions WHERE revision = $2),
|
|
(SELECT COUNT(*) FROM test_publications WHERE id = $3),
|
|
(SELECT COUNT(*) FROM jobs WHERE idempotency_key = $4)`,
|
|
request.SHA256,
|
|
"sha256:"+request.SHA256,
|
|
first.PublicationID,
|
|
"test-upload:"+first.PublicationID,
|
|
).Scan(&sourceType, &connectionCount, &itemCount, &contentCount, &versionCount, &publicationCount, &jobCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sourceType != "manual" || connectionCount != 1 || itemCount != 1 || contentCount != 1 || versionCount != 1 || publicationCount != 1 || jobCount != 1 {
|
|
t.Fatalf("seed counts/type = %q %d/%d/%d/%d/%d/%d", sourceType, connectionCount, itemCount, contentCount, versionCount, publicationCount, jobCount)
|
|
}
|
|
}
|