Files
knowledge-governance/internal/httpapi/qa_monthly_test.go
T

222 lines
9.5 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/http/httptest"
"strconv"
"net/url"
"strings"
"testing"
"time"
)
type monthlyReaderSpy struct {
attributedReader
query dobotaiops.RecordQuery
calls int
}
func (s *monthlyReaderSpy) ReadUsageGroups(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error) {
s.calls++
s.query = q
return s.attributedReader.ReadUsageGroups(ctx, q)
}
func monthlyRequest(h http.Handler, path, token string) *httptest.ResponseRecorder {
u, _ := url.Parse(path)
body := map[string]any{}
for k, v := range u.Query() {
if len(v) != 1 {
body[k] = v
continue
}
if k == "page" || k == "limit" {
if n, e := strconv.Atoi(v[0]); e == nil {
body[k] = n
} else {
body[k] = v[0]
}
} else {
body[k] = v[0]
}
}
raw, _ := json.Marshal(body)
r := httptest.NewRequest(http.MethodPost, u.Path, bytes.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Governance-Action", "qa-usage-read")
if token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func monthlyHandler(t *testing.T) (http.Handler, *attributionOrgSpy, *monthlyReaderSpy) {
t.Helper()
d := qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "示例根"}, {ID: "child", ParentID: "root", Name: "子部门"}}, People: []qaopsorg.Person{{ID: "fixture-private-staff", Name: "同名示例", UnionID: "fixture-private-union", DepartmentIDs: []string{"root"}}, {ID: "private-zero", Name: "同名示例", DepartmentIDs: []string{"child"}}, {ID: "private-cross", Name: "跨区域示例", DepartmentIDs: []string{"root", "child"}}, {ID: "private-unnamed", DepartmentIDs: []string{"child"}}}}
now := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
p, err := qaopsorg.BuildAttribution(qaopsorg.State{State: "ready", DirectoryID: "monthly-directory", DirectorySource: "weekly_report_live", DirectorySyncedAt: &now, RevisionID: "monthly-regions", Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "国内工业"}, {DepartmentID: "child", Region: "APAC"}}}, d)
if err != nil {
t.Fatal(err)
}
org := &attributionOrgSpy{projection: p}
reader := &monthlyReaderSpy{}
auth := newFakeAuthStore()
auth.sessions["attribution-admin"] = "usr-boss"
auth.sessions["attribution-viewer"] = "usr-eye"
h, err := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: reader, QAOpsTenantKey: "fixed", QAOrg: org, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return h, org, reader
}
func TestQAMonthlyPeopleIncludesZeroUseAndKeepsUnresolvedEvents(t *testing.T) {
h, _, reader := monthlyHandler(t)
path := "/api/v1/qa-ops/usage/people?month=2026-10&limit=2"
w := monthlyRequest(h, path, "attribution-admin")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var result struct {
Data struct {
Month string
Timezone string
Partial bool
Questions int64
LinkedQuestions int64 `json:"linked_questions"`
UnresolvedQuestions int64 `json:"unresolved_questions"`
RosterPeople int `json:"roster_people"`
UsedPeople int `json:"used_people"`
ZeroPeople int `json:"zero_people"`
MissingNames int `json:"missing_names"`
Total int
HasMore bool `json:"has_more"`
Rows []struct {
Key string
Name string
Questions int64
State string
}
}
}
if json.Unmarshal(w.Body.Bytes(), &result) != nil {
t.Fatal("invalid monthly JSON")
}
v := result.Data
if v.Month != "2026-10" || v.Timezone != "Asia/Shanghai" || !v.Partial || v.Questions != 7 || v.LinkedQuestions != 5 || v.UnresolvedQuestions != 2 || v.RosterPeople != 4 || v.UsedPeople != 1 || v.ZeroPeople != 3 || v.MissingNames != 1 || v.Total != 4 || len(v.Rows) != 2 || !v.HasMore {
t.Fatal("monthly roster, zero count or alias dedup wrong", w.Body.String())
}
if reader.query.Window.TenantKey != "fixed" || reader.query.Window.Start.Format(time.RFC3339) != "2026-10-01T00:00:00+08:00" || reader.query.Window.End.Format(time.RFC3339) != "2026-10-17T16:00:00+08:00" {
t.Fatal("month was not normalized to the named timezone")
}
if strings.Contains(w.Body.String(), "private-") || strings.Contains(w.Body.String(), "performance") || strings.Contains(w.Body.String(), "staff_id") {
t.Fatal("raw identities or unapproved verdict exposed")
}
page2 := monthlyRequest(h, path+"&page=2&directory_id=monthly-directory&revision_id=monthly-regions", "attribution-admin")
if page2.Code != 200 {
t.Fatal(page2.Body.String())
}
for _, extra := range []string{"&activity=zero", "&region=cross_region", "&department_id=child", "&search=" + url.QueryEscape("子部门")} {
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+extra, "attribution-admin")
if r.Code != 200 {
t.Fatal("valid monthly filter failed", r.Body.String())
}
}
}
func TestQAMonthlyScopeAndVersionFailClosed(t *testing.T) {
h, org, reader := monthlyHandler(t)
base := "/api/v1/qa-ops/usage/people?month=2026-10"
for _, tc := range []struct {
suffix, token string
status int
}{{"", "", 401}, {"", "attribution-viewer", 403}, {"&tenant=forged", "attribution-admin", 400}, {"&threshold=5", "attribution-admin", 400}, {"&month=duplicate", "attribution-admin", 400}, {"&page=0", "attribution-admin", 400}, {"&limit=101", "attribution-admin", 400}, {"&activity=under5", "attribution-admin", 400}, {"&region=invalid", "attribution-admin", 400}, {"&department_id=invalid", "attribution-admin", 400}, {"&page=2", "attribution-admin", 400}, {"&page=2&directory_id=changed&revision_id=monthly-regions", "attribution-admin", 409}, {"&cutoff=2026-11-01T00:00:00Z", "attribution-admin", 400}} {
w := monthlyRequest(h, base+tc.suffix, tc.token)
if w.Code != tc.status {
t.Fatalf("monthly %s: %d want %d", tc.suffix, w.Code, tc.status)
}
}
for _, month := range []string{"2026-1", " 2026-10", "2026-13", "0000-01", "2026-11"} {
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month="+url.QueryEscape(month), "attribution-admin")
if r.Code != 400 {
t.Fatal("invalid/future month accepted")
}
}
if reader.calls != 0 {
t.Fatal("invalid or unauthorized monthly request reached source")
}
org.projection, _ = qaopsorg.BuildAttribution(qaopsorg.State{State: "empty"}, qaopsorg.Directory{})
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 409 {
t.Fatal("no roster presented as zero-use list")
}
org.err = qaopsorg.ErrUnavailable
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 503 {
t.Fatal("store failure hidden as empty")
}
}
func TestQAMonthlyPersonFiltersDoNotGuessNamesOrDuplicateDescendantMembers(t *testing.T) {
h, _, _ := monthlyHandler(t)
for _, tc := range []struct {
filter string
total int
}{{"&activity=used", 1}, {"&activity=zero", 3}, {"&region=cross_region", 1}, {"&region=APAC", 2}, {"&department_id=child", 3}, {"&department_id=root", 4}, {"&search=" + url.QueryEscape("同名示例"), 2}, {"&search=" + url.QueryEscape("子部门"), 3}, {"&search=%25_", 0}} {
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+tc.filter, "attribution-admin")
var payload struct{ Data monthlyPeople }
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &payload) != nil || payload.Data.Total != tc.total || payload.Data.Questions != 7 {
t.Fatal("person-scope filter did not preserve source totals or person dedup", tc.filter)
}
for _, r := range payload.Data.Rows {
if tc.filter == "&activity=zero" && r.Questions != 0 {
t.Fatal("used member labeled zero")
}
}
}
}
func TestQAMonthlyReadBodyRejectsDuplicateNullTrailingAndOversizedInput(t *testing.T) {
h, _, reader := monthlyHandler(t)
for _, body := range []string{`{"month":"2026-10","month":"2026-10"}`, `{"month":"2026-10","search":null}`, `{"month":"2026-10","page":"1"}`, `{"month":"2026-10"}{}`, `[]`, `{"month":"2026-10","search":"` + strings.Repeat("x", 9000) + `"}`} {
r := httptest.NewRequest("POST", "/api/v1/qa-ops/usage/people", strings.NewReader(body))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
r.Header.Set("X-Governance-Action", "qa-usage-read")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 400 {
t.Fatal("invalid read body reached source", w.Code)
}
}
for _, method := range []string{"POST", "GET"} {
r := httptest.NewRequest(method, "/api/v1/qa-ops/usage/people?search=sensitive-example", strings.NewReader(`{"month":"2026-10"}`))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 400 && w.Code != 405 {
t.Fatal("URL-based name query accepted")
}
}
if reader.calls != 0 {
t.Fatal("invalid body read production source")
}
}
func TestQAMonthlyPastLeapMonthUsesClosedCalendarWindow(t *testing.T) {
h, _, reader := monthlyHandler(t)
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2024-02", "attribution-admin")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if reader.query.Window.End.Format(time.RFC3339) != "2024-03-01T00:00:00+08:00" || reader.query.Window.End.Sub(reader.query.Window.Start) != 29*24*time.Hour {
t.Fatal("leap month boundary incorrect")
}
var raw struct{ Data struct{ Partial bool } }
_ = json.Unmarshal(w.Body.Bytes(), &raw)
if raw.Data.Partial {
t.Fatal("past natural month mislabeled as current progress")
}
}