Files
姚章浩 828c2c5fa0
build / build (push) Successful in 1m6s
feat(qa-ops): 按确认口径补齐质量比例与分母明细
使用完整筛选范围的当前有效结论计算覆盖率和正确率,人工优先,失效与对应待确认/未评估/不计入排除;零分母返回null,界面展示原始分子分母和排除构成,前端拒绝不守恒或比例不一致的响应。

验证:make verify退出码0,Go658及前端257通过,91项声明可选集成跳过;隔离PG、三包竞态及合成/真实只读源浏览器验证通过。无新增迁移,不接AI模型,不改绩效,不写源库;本批未推送或部署,8088运行版保持0.1.21。
2026-10-18 02:00:31 +08:00

180 lines
8.2 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsquality"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type qualityFake struct {
actor string
calls int
fail bool
}
func (s *qualityFake) Observe(_ context.Context, records []dobotaiops.Record) (map[string]qaopsquality.View, error) {
s.calls++
if s.fail {
return nil, qaopsquality.ErrUnavailable
}
out := map[string]qaopsquality.View{}
for _, r := range records {
e, _ := qaopsquality.EvidenceFor(r)
out[r.ID] = qaopsquality.Project(e.Hash, 1, 0, qaopsquality.Check{InputEpoch: 1, InputHash: e.Hash, Result: dobotaiops.CheckAnswerCoverage(r.Answer)}, nil)
}
return out, nil
}
func (s *qualityFake) ObserveKeys(_ context.Context, keys []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) {
s.calls++
out := map[string]qaopsquality.View{}
for _, key := range keys {
out[key.ID] = qaopsquality.Project(key.InputHash, 1, 0, qaopsquality.Check{Result: dobotaiops.CheckMarkerCoverage(key.Marker)}, nil)
}
return out, nil
}
func (s *qualityFake) Save(_ context.Context, r dobotaiops.Record, q qaopsquality.SaveRequest, actor string) (qaopsquality.Receipt, error) {
s.actor = actor
v := qaopsquality.Project(q.InputHash, 1, 1, qaopsquality.Check{Result: dobotaiops.CheckAnswerCoverage(r.Answer)}, &qaopsquality.Review{InputEpoch: 1, InputHash: q.InputHash, Fields: q.Fields, Reason: q.Reason, Reviewer: actor})
return qaopsquality.Receipt{View: v, SavedRevision: 1}, nil
}
func (s *qualityFake) Columns(_ context.Context, keys []dobotaiops.QualityKey) ([]string, error) {
return []string{"is_correct", "product_category"}, nil
}
func (s *qualityFake) History(context.Context, string, int64, int64) (qaopsquality.History, error) {
return qaopsquality.History{Reviews: []qaopsquality.Review{}, Checks: []qaopsquality.Check{}}, nil
}
func (s *qualityFake) Snapshot(context.Context, string, int64) (qaopsquality.Snapshot, error) {
return qaopsquality.Snapshot{}, qaopsquality.ErrNotFound
}
type qualitySource struct {
attributedReader
record dobotaiops.Record
tenant string
scopeCalls int
}
func (s *qualitySource) GetRecord(_ context.Context, tenant, id string) (dobotaiops.Record, error) {
s.tenant = tenant
return s.record, nil
}
func (s *qualitySource) ListRecords(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
return dobotaiops.RecordPage{Items: []dobotaiops.Record{s.record}, Columns: []string{"question"}, Total: 2, AsOf: time.Now()}, nil
}
func (s *qualitySource) ReadQualityScope(_ context.Context, q dobotaiops.RecordQuery) (dobotaiops.QualityScope, error) {
s.scopeCalls++
s.tenant = q.Window.TenantKey
second := s.record
second.ID = "second-turn"
keys := []dobotaiops.QualityKey{}
for _, r := range []dobotaiops.Record{s.record, second} {
e, _ := qaopsquality.EvidenceFor(r)
keys = append(keys, dobotaiops.QualityKey{ID: r.ID, InputHash: e.Hash, Marker: e.Result.Verdict == "uncovered", Source: r.SourceCoverage})
}
return dobotaiops.QualityScope{Keys: keys, AsOf: time.Now()}, nil
}
func qualityTestHandler(t *testing.T) (http.Handler, *qualityFake, *qualitySource) {
t.Helper()
store := &qualityFake{}
source := &qualitySource{record: dobotaiops.Record{ID: "fixture-turn", Status: "succeeded", Question: "示例", Answer: "暂无已发布"}}
auth := newFakeAuthStore()
auth.sessions["quality-admin"] = "usr-boss"
auth.sessions["quality-viewer"] = "usr-eye"
h, e := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: source, QAOpsTenantKey: "fixed", QAQuality: store, Now: func() time.Time { return time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) }})
if e != nil {
t.Fatal(e)
}
return h, store, source
}
func qualityRequest(h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Governance-Action", "qa-quality")
if token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestQAQualityDetailListAndWholeScopeCounts(t *testing.T) {
h, store, source := qualityTestHandler(t)
w := qualityRequest(h, "GET", "/api/v1/qa-ops/records/fixture-turn", "quality-admin", "")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"quality"`) {
t.Fatal("persisted quality absent in detail", w.Code)
}
w = qualityRequest(h, "GET", "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z", "quality-admin", "")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"is_correct"`) {
t.Fatal("whole-filter quality columns absent")
}
w = qualityRequest(h, "POST", "/api/v1/qa-ops/quality/query", "quality-admin", `{"start":"2026-10-14T00:00:00Z","end":"2026-10-15T00:00:00Z","keyword":""}`)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"unreviewed":2`) || source.scopeCalls != 1 || source.tenant != "fixed" || store.calls < 2 {
t.Fatal("quality summary estimated from page instead of scope", w.Code, w.Body.String())
}
var counts struct {
Data struct {
Summary qaopsquality.Summary `json:"summary"`
} `json:"data"`
}
if json.Unmarshal(w.Body.Bytes(), &counts) != nil || counts.Data.Summary.Rates.Coverage.Denominator != 2 || counts.Data.Summary.Rates.Coverage.Numerator != 0 || counts.Data.Summary.Rates.Correctness.Percent != nil {
t.Fatal("confirmed quality metrics were not computed for the whole scope")
}
store.fail = true
w = qualityRequest(h, "GET", "/api/v1/qa-ops/records/fixture-turn", "quality-admin", "")
if w.Code != 503 || !strings.Contains(w.Body.String(), "quality_unavailable") {
t.Fatal("quality storage failure presented as empty success")
}
}
func TestQAQualityReviewUsesSessionActorAndStrictReadOnlySourceBoundary(t *testing.T) {
h, store, source := qualityTestHandler(t)
e, _ := qaopsquality.EvidenceFor(source.record)
q := qaopsquality.SaveRequest{InputHash: e.Hash, InputEpoch: 1, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "uncovered", Correctness: "pending"}, Reason: "人工核对"}
raw, _ := json.Marshal(q)
path := "/api/v1/qa-ops/records/fixture-turn/reviews"
w := qualityRequest(h, "POST", path, "quality-admin", string(raw))
if w.Code != 200 || store.actor != "boss" || source.tenant != "fixed" {
t.Fatal("manual review failed or actor came from request", w.Code, store.actor)
}
for _, tc := range []struct {
token, body string
status int
}{{"", string(raw), 401}, {"quality-viewer", string(raw), 403}, {"quality-admin", strings.TrimSuffix(string(raw), "}") + `,"reviewer":"forged"}`, 400}, {"quality-admin", `null`, 400}, {"quality-admin", string(raw) + `{}`, 400}, {"quality-admin", `{"nonce":"a","nonce":"b"}`, 400}} {
w = qualityRequest(h, "POST", path, tc.token, tc.body)
if w.Code != tc.status {
t.Fatal("review boundary failed", w.Code, tc.status)
}
}
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "quality-admin"})
r.Header.Set("X-Governance-Action", "qa-quality")
r.Header.Set("Sec-Fetch-Site", "cross-site")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("cross-site review accepted")
}
w = qualityRequest(h, "GET", path+"/invalid", "quality-admin", "")
if w.Code != 404 {
t.Fatal("unknown endpoint silently accepted")
}
}
func TestQAQualityReviewRejectsMissingRevisionAndNestedNull(t *testing.T) {
h, _, source := qualityTestHandler(t)
e, _ := qaopsquality.EvidenceFor(source.record)
q := qaopsquality.SaveRequest{InputHash: e.Hash, InputEpoch: 1, Nonce: strings.Repeat("n", 32), Fields: qaopsquality.Fields{Coverage: "pending", Correctness: "pending"}, Reason: "示例复核"}
raw, _ := json.Marshal(q)
for _, body := range []string{strings.Replace(string(raw), `"base_revision":0,`, "", 1), strings.Replace(string(raw), `"base_revision":0`, `"base_revision":null`, 1), strings.Replace(string(raw), `"product_category":""`, `"product_category":null`, 1)} {
w := qualityRequest(h, "POST", "/api/v1/qa-ops/records/fixture-turn/reviews", "quality-admin", body)
if w.Code != 400 {
t.Fatal("missing/null version or field silently became a default", w.Code)
}
}
}