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) } } }