4b3aaff321
治理侧保存输入观测版本、自动复检及人工复核历史,输入变化失效、版本冲突和幂等重放不覆盖人工记录。批量质量数量使用完整筛选元数据,未采集原文不伪造快照,延迟旧扫描不回退新输入。 验证:全仓 make verify 退出码0,Go656及前端253通过;五包竞态、源/组织/质量隔离PG、真实只读源和隔离网页读回复核通过。默认91个可选集成跳过不计通过。未接AI模型,不写DobotAI源库,未推送、未合并、未部署。
220 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|