Files
knowledge-governance/internal/qaopsquality/store_integration_test.go
T
姚章浩 4b3aaff321 feat(qa-ops): 完成复检留档与人工复核闭环
治理侧保存输入观测版本、自动复检及人工复核历史,输入变化失效、版本冲突和幂等重放不覆盖人工记录。批量质量数量使用完整筛选元数据,未采集原文不伪造快照,延迟旧扫描不回退新输入。

验证:全仓 make verify 退出码0,Go656及前端253通过;五包竞态、源/组织/质量隔离PG、真实只读源和隔离网页读回复核通过。默认91个可选集成跳过不计通过。未接AI模型,不写DobotAI源库,未推送、未合并、未部署。
2026-10-17 22:37:26 +08:00

220 lines
8.7 KiB
Go

package qaopsquality_test
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/dobotaiops"
pg "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/qaopsquality"
"net/url"
"os"
"strings"
"testing"
"time"
)
func TestQualityStoreImmutableHistoryReplayConcurrencyAndStaleness(t *testing.T) {
dsn := os.Getenv("KG_QAOPS_QUALITY_TEST_DSN")
if dsn == "" {
t.Skip("explicit disposable quality DB required")
}
u, e := url.Parse(dsn)
if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_quality_test_") || os.Getenv("KG_QAOPS_QUALITY_TEST_ISOLATED") != "1" {
t.Fatal("refusing shared quality database")
}
ctx := context.Background()
pool, e := pgxpool.New(ctx, dsn)
if e != nil {
t.Fatal("fixture connection failed")
}
defer pool.Close()
if e = pg.ApplyMigrations(ctx, pool); e != nil {
t.Fatal(e)
}
store := qaopsquality.NewStore(pool, "fixture-tenant")
record := dobotaiops.Record{ID: "fixture-turn", Status: "succeeded", Question: "示例", Answer: "暂无已发布", AskedAt: time.Now()}
views, e := store.Observe(ctx, []dobotaiops.Record{record})
if e != nil {
t.Fatal("source observation not persisted", e)
}
first := views[record.ID]
again, e := store.Observe(ctx, []dobotaiops.Record{record})
if e != nil || again[record.ID].Automatic.ID != first.Automatic.ID {
t.Fatal("repeated GET duplicated immutable checks")
}
metaKey := dobotaiops.QualityKey{ID: "metadata-only-turn", InputHash: strings.Repeat("f", 64), Marker: true}
meta, me := store.ObserveKeys(ctx, []dobotaiops.QualityKey{metaKey})
if me != nil || meta[metaKey.ID].Automatic.InputAvailable {
t.Fatal("compact scope observation not stored honestly", me)
}
if _, me = store.Snapshot(ctx, metaKey.ID, 1); !errors.Is(me, qaopsquality.ErrSnapshotMissing) {
t.Fatal("metadata-only input fabricated an empty original")
}
full, me := store.Observe(ctx, []dobotaiops.Record{{ID: metaKey.ID, QualityInputHash: metaKey.InputHash, Question: "示例", Answer: "暂无已发布"}})
if me != nil || !full[metaKey.ID].Automatic.InputAvailable || full[metaKey.ID].Automatic.ID != meta[metaKey.ID].Automatic.ID {
t.Fatal("reading full content duplicated/replaced the same check")
}
req := qaopsquality.SaveRequest{InputHash: first.InputHash, InputEpoch: first.InputEpoch, BaseRevision: first.Revision, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "covered", Correctness: "correct", ProductCategory: "MG400", ProblemType: "咨询"}, Reason: "人工核对有效引用"}
saved, e := store.Save(ctx, record, req, "fixture-admin")
if e != nil || saved.View.State != "reviewed" || saved.View.Effective.Coverage != "covered" || saved.View.Automatic.Result.Verdict != "uncovered" || saved.View.LatestReview.Reviewer != "fixture-admin" {
t.Fatal("manual override lost audit/rule separation", e)
}
cols, ce := store.Columns(ctx, []dobotaiops.QualityKey{{ID: record.ID, InputHash: first.InputHash}})
if ce != nil || len(cols) != 4 {
t.Fatal("review fields absent from whole-scope projection", ce)
}
replay, e := store.Save(ctx, record, req, "fixture-admin")
if e != nil || !replay.Replayed || replay.SavedRevision != saved.SavedRevision {
t.Fatal("retry duplicated review")
}
bad := req
bad.Reason = "changed body"
if _, e = store.Save(ctx, record, bad, "fixture-admin"); !errors.Is(e, qaopsquality.ErrConflict) {
t.Fatal("nonce reused with different request")
}
if _, e = store.Save(ctx, record, req, "other-admin"); !errors.Is(e, qaopsquality.ErrConflict) {
t.Fatal("nonce replay changed operator")
}
record.Answer = "新答案"
changed, e := store.Observe(ctx, []dobotaiops.Record{record})
if e != nil || changed[record.ID].State != "stale" || changed[record.ID].Effective.Correctness != "" {
t.Fatal("old manual result silently survived change")
}
if _, e = store.Save(ctx, record, req, "fixture-admin"); e != nil {
t.Fatal("old completed request should replay without a new review")
}
record.Answer = "暂无已发布"
reverted, e := store.Observe(ctx, []dobotaiops.Record{record})
v := reverted[record.ID]
if e != nil || v.InputHash != first.InputHash || v.InputEpoch <= first.InputEpoch || v.State != "stale" {
t.Fatal("answer revert reactivated old judgment")
}
req.InputHash = v.InputHash
req.InputEpoch = v.InputEpoch
req.BaseRevision = v.Revision
start := make(chan struct{})
results := make(chan error, 2)
for _, nonce := range []string{strings.Repeat("a", 32), strings.Repeat("b", 32)} {
go func(nonce string) {
q := req
q.Nonce = nonce
<-start
_, err := store.Save(ctx, record, q, "fixture-admin")
results <- err
}(nonce)
}
close(start)
success, conflict := 0, 0
for range 2 {
e = <-results
if e == nil {
success++
} else if errors.Is(e, qaopsquality.ErrConflict) {
conflict++
} else {
t.Fatal(e)
}
}
if success != 1 || conflict != 1 {
t.Fatal("concurrent reviewers overwrote each other")
}
h, e := store.History(ctx, record.ID, 0, 0)
if e != nil || len(h.Reviews) != 2 || len(h.Checks) != 3 || h.Checks[0].InputEpoch != 3 {
t.Fatal("history missing/replaced", e)
}
oldInput, oe := store.Snapshot(ctx, record.ID, 1)
if oe != nil || oldInput.InputHash != first.InputHash || !strings.Contains(string(oldInput.Payload), "暂无已发布") {
t.Fatal("old assessment evidence not readable")
}
// A slow older source scan must not roll back a newer observed input.
ordered := dobotaiops.Record{ID: "ordered-source-turn", Answer: "旧输入", QualityObservedAt: time.Now().Add(-time.Minute)}
oldEvidence, _ := qaopsquality.EvidenceFor(ordered)
if _, e = store.Observe(ctx, []dobotaiops.Record{ordered}); e != nil {
t.Fatal(e)
}
ordered.Answer = "新输入"
ordered.QualityObservedAt = ordered.QualityObservedAt.Add(time.Second)
newViews, ne := store.Observe(ctx, []dobotaiops.Record{ordered})
if ne != nil {
t.Fatal(ne)
}
newView := newViews[ordered.ID]
if _, e = store.ObserveKeys(ctx, []dobotaiops.QualityKey{{ID: ordered.ID, InputHash: oldEvidence.Hash, ObservedAt: ordered.QualityObservedAt.Add(-time.Second)}}); !errors.Is(e, qaopsquality.ErrConflict) {
t.Fatal("late old source scan rolled back the current input")
}
current, ce := store.Observe(ctx, []dobotaiops.Record{ordered})
if ce != nil || current[ordered.ID].InputEpoch != newView.InputEpoch {
t.Fatal("stale scan created extra input epochs")
}
// Independent cursors must enumerate more than one page without loss.
paging := dobotaiops.Record{ID: "history-paging-turn", Answer: "示例"}
pageSeen := map[int64]bool{}
checkSeen := map[int64]bool{}
for i := 0; i < 23; i++ {
paging.Answer = fmt.Sprintf("示例输入%d", i)
vv, ee := store.Observe(ctx, []dobotaiops.Record{paging})
if ee != nil {
t.Fatal(ee)
}
value := vv[paging.ID]
_, ee = store.Save(ctx, paging, qaopsquality.SaveRequest{InputHash: value.InputHash, InputEpoch: value.InputEpoch, BaseRevision: value.Revision, Nonce: fmt.Sprintf("history_%020d", i), Fields: qaopsquality.Fields{Coverage: "pending", Correctness: "pending"}, Reason: "隔离分页验证"}, "fixture-admin")
if ee != nil {
t.Fatal(ee)
}
}
rc, cc := int64(0), int64(0)
for {
page, ee := store.History(ctx, paging.ID, rc, cc)
if ee != nil {
t.Fatal(ee)
}
for _, r := range page.Reviews {
if pageSeen[r.Revision] {
t.Fatal("duplicate history review")
}
pageSeen[r.Revision] = true
}
for _, c := range page.Checks {
if checkSeen[c.ID] {
t.Fatal("duplicate history check")
}
checkSeen[c.ID] = true
}
rc, cc = page.NextReview, page.NextCheck
if rc == 0 && cc == 0 {
break
}
if rc == 0 || cc == 0 {
t.Fatal("unexpected fixture cursor shape")
}
}
if len(pageSeen) != 23 || len(checkSeen) != 23 {
t.Fatal("history cursor omitted immutable rows")
}
freshPool, fe := pgxpool.New(ctx, dsn)
if fe != nil {
t.Fatal(fe)
}
persisted, fe := qaopsquality.NewStore(freshPool, "fixture-tenant").History(ctx, record.ID, 0, 0)
freshPool.Close()
if fe != nil || len(persisted.Reviews) != 2 || len(persisted.Checks) != 3 {
t.Fatal("history did not survive a new connection pool")
}
// History survives a fresh store/connection, not merely an in-memory cache.
if h, e = qaopsquality.NewStore(pool, "other-tenant").History(ctx, record.ID, 0, 0); e != nil || len(h.Reviews) != 0 || len(h.Checks) != 0 {
t.Fatal("quality history crossed tenants")
}
if e = pg.RollbackLatestMigration(ctx, pool); e != nil {
t.Fatal(e)
}
var gone bool
if pool.QueryRow(ctx, `SELECT to_regclass('qa_ops_quality_records') IS NULL AND to_regclass('qa_ops_quality_inputs') IS NULL AND to_regclass('qa_ops_quality_checks') IS NULL AND to_regclass('qa_ops_quality_reviews') IS NULL`).Scan(&gone) != nil || !gone {
t.Fatal("quality down migration left data tables")
}
if e = pg.ApplyMigrations(ctx, pool); e != nil {
t.Fatal(e)
}
}