Files
knowledge-governance/internal/qaopsmirror/store_integration_test.go
T
姚章浩 3e7fcce0b1
build / build (push) Successful in 1m11s
feat(qa-ops): 持久本地问答副本与安全答案预览
新增迁移50与每5分钟/手动持久同步,API只读本地;同步失败保留、权限阻断、租约与质量失效原子激活。
保留全量稳定分页/固定列,已存质量批量读取,元数据不反复解压长答案;详情安全Markdown与原文。

已运行整仓 make verify、源与副本隔离PG -race、真实只读源及隔离浏览器。
实际同步2525条;固定窗口640条本地中位1.46秒,对照直接源33.10秒。
停worker、缺源凭据、重启API后仍可查询;未更新8088,未修改生产原问答。
2026-10-18 19:10:47 +08:00

77 lines
3.6 KiB
Go

package qaopsmirror
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/dobotaiops"
pg "knowledge-governance/internal/platform/postgres"
"net/url"
"os"
"strings"
"testing"
"time"
)
func mirrorFixture(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("KG_QAOPS_MIRROR_TEST_DSN")
if dsn == "" {
t.Skip("explicit disposable mirror database required")
}
u, e := url.Parse(dsn)
if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_mirror_test_") || os.Getenv("KG_QAOPS_MIRROR_TEST_ISOLATED") != "1" {
t.Fatal("refusing shared mirror database")
}
p, e := pgxpool.New(context.Background(), dsn)
if e != nil {
t.Fatal("fixture connection failed")
}
t.Cleanup(p.Close)
if e = pg.ApplyMigrations(context.Background(), p); e != nil {
t.Fatal(e)
}
return p
}
func TestLocalMirrorQueriesNeverRequireSource(t *testing.T) {
p := mirrorFixture(t)
ctx := context.Background()
s := NewStore(p, "fixture-mirror")
if s.Available(ctx) == nil {
t.Fatal("an unsynchronized mirror pretended to be empty/ready")
}
asked := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
wire := map[string]any{"id": "fixture-one", "status": "succeeded", "question": "literal 100%_ question", "answer": "# local answer\n暂无已发布", "asked_at": asked, "time_source": "turn.created_at", "asker": "fixture name", "trigger_person": "", "department": "", "region": "", "references": []any{}, "media": []any{}, "source_coverage": map[string]string{"verdict": "covered", "reason_code": "", "evaluator_version": "v5"}, "feedback": map[string]any{"asker_value": "up", "up": 1, "down": 0}, "_identity": map[string]string{"actor_id": "actor1", "staff_id": "staff1", "union_id": ""}, "_quality_hash": strings.Repeat("a", 64), "_quality_observed_at": time.Now().UTC()}
raw, _ := json.Marshal(wire)
if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key,ready,revision,record_count,source_observed_at,synced_at) VALUES($1,true,1,1,now(),now())`, s.tenant); e != nil {
t.Fatal(e)
}
if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,'fixture-one',$2,$3,$4,'succeeded',$5,$2,$6)`, s.tenant, strings.Repeat("a", 64), asked, wire["question"], `{"actor_id":"actor1","staff_id":"staff1","union_id":""}`, raw); e != nil {
t.Fatal(e)
}
// No source client or source credentials have been constructed anywhere in this test.
if e := s.Available(ctx); e != nil {
t.Fatal("local serving still requires a remote source", e)
}
q := dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: asked.Add(-time.Minute), End: asked.Add(time.Minute)}, Keyword: "100%_", Limit: 1}
page, e := s.ListRecords(ctx, q)
if e != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].Answer != wire["answer"] {
t.Fatal("local fixed-tenant literal query failed", e, page.Total)
}
if page.Items[0].Identity.StaffID != "staff1" || page.Items[0].Coverage.Verdict != "uncovered" {
t.Fatal("local copy lost source identity or deterministic rule")
}
out, _ := json.Marshal(page)
if strings.Contains(string(out), "staff1") || strings.Contains(string(out), "_identity") {
t.Fatal("private identity leaked to browser")
}
got, e := s.GetRecord(ctx, s.tenant, "fixture-one")
if e != nil || got.Answer != wire["answer"] {
t.Fatal("detail is not served from local data", e)
}
if _, e = s.GetRecord(ctx, "other-tenant", "fixture-one"); !errors.Is(e, dobotaiops.ErrInvalidQuery) {
t.Fatal("cross-tenant detail was not rejected", e)
}
}