Files
knowledge-governance/internal/httpapi/version_history_test.go
T
姚章浩 013ce4a24e
build / build (push) Successful in 1m3s
fix(version): 补全开发快照记录与同步交付闭环
收录 main、注释开发快照和完整运行 SHA;开发说明绑定原提交,补记 0.1.1–0.1.25。
进行中同步按仓库合并,完成后重新执行;启动入队,部署脚本读回版本证据。
开发快照独立于正式标签与 Release,既有冲突标签不移动。

已运行:整仓 make verify,Go 673 通过、前端 274 通过,92 个已声明可选跳过,必需跳过 0。
隔离 PostgreSQL 重复同步队列用例及 -race 通过;未更新现有 8088、未修改生产问答。
2026-10-18 18:11:49 +08:00

287 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/version"
)
type fakeVersionHistoryStore struct {
items []governance.VersionHistoryCommit
nextCursor string
hasMore bool
snapshot int64
pageErr error
state governance.VersionHistorySyncRun
stateErr error
inFlight bool
inFlightErr error
lastKind string
lastCursor string
lastLimit int
}
func (f *fakeVersionHistoryStore) ActiveVersionHistorySnapshot(context.Context, string) (int64, error) {
return f.snapshot, nil
}
func (f *fakeVersionHistoryStore) LoadVersionHistoryPage(_ context.Context, _, kind, cursor string, limit int) (
[]governance.VersionHistoryCommit, string, bool, int64, error) {
f.lastKind, f.lastCursor, f.lastLimit = kind, cursor, limit
if f.pageErr != nil {
return nil, "", false, f.snapshot, f.pageErr
}
return f.items, f.nextCursor, f.hasMore, f.snapshot, nil
}
func (f *fakeVersionHistoryStore) LoadVersionHistorySyncState(context.Context, string) (governance.VersionHistorySyncRun, error) {
return f.state, f.stateErr
}
func (f *fakeVersionHistoryStore) VersionHistorySyncInFlight(context.Context, string, time.Time) (bool, error) {
return f.inFlight, f.inFlightErr
}
func decodeVersionHistoryBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("响应不是 JSON:%v(%s)", err, recorder.Body.String())
}
return payload
}
// 注入的完整 SHA 决定「当前运行」;不注入就只能是未知 —— 绝不拿最新版本顶替。
func withInjectedIdentity(t *testing.T, sha, kind string) {
t.Helper()
previousSHA, previousKind, previousRelease := version.CommitSHA, version.BuildKind, version.ReleaseVersion
version.CommitSHA, version.BuildKind, version.ReleaseVersion = sha, kind, "0.1.0"
t.Cleanup(func() {
version.CommitSHA, version.BuildKind, version.ReleaseVersion = previousSHA, previousKind, previousRelease
})
}
func TestBuildInfoReportsIdentityAvailability(t *testing.T) {
sha := strings.Repeat("a", 40)
withInjectedIdentity(t, sha, "development")
recorder := httptest.NewRecorder()
buildInfo(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/build-info", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("状态码 = %d", recorder.Code)
}
payload := decodeVersionHistoryBody(t, recorder)
if payload["identity_available"] != true || payload["commit_sha"] != sha {
t.Fatalf("完整注入时应给出身份:%v", payload)
}
if payload["build_kind"] != "development" {
t.Fatalf("构建类型 = %v", payload["build_kind"])
}
withInjectedIdentity(t, "unknown", "release")
recorder = httptest.NewRecorder()
buildInfo(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/build-info", nil))
payload = decodeVersionHistoryBody(t, recorder)
if payload["identity_available"] != false || payload["commit_sha"] != nil || payload["display_version"] != "dev" {
t.Fatalf("身份不可用时必须明确说未知:%v", payload)
}
}
func TestVersionHistoryListUnconfiguredStaysReadable(t *testing.T) {
withInjectedIdentity(t, "unknown", "development")
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), Config{})
if recorder.Code != http.StatusOK {
t.Fatalf("未接线也必须 200(页面降级展示):%d", recorder.Code)
}
payload := decodeVersionHistoryBody(t, recorder)
sync, _ := payload["sync"].(map[string]any)
if sync["available"] != false {
t.Fatalf("未接线时 sync.available 必须为 false:%v", sync)
}
items, _ := payload["items"].([]any)
if len(items) != 0 {
t.Fatalf("未接线时不该有记录:%v", items)
}
running, _ := payload["current_running"].(map[string]any)
if running["state"] != "unknown" {
t.Fatalf("拿不到身份必须显示未知:%v", running)
}
}
func TestVersionHistoryListMarksOnlyExactMatchAndKeepsSameNumberApart(t *testing.T) {
sha := strings.Repeat("b", 40)
other := strings.Repeat("c", 40)
withInjectedIdentity(t, sha, "development")
store := &fakeVersionHistoryStore{snapshot: 7, hasMore: true, nextCursor: "cursor-1",
items: []governance.VersionHistoryCommit{
{CommitSHA: sha, CommittedAt: time.Date(2026, 10, 13, 10, 0, 0, 0, time.UTC),
Subject: "feat: 当前运行的这条", RecordKind: governance.RecordKindDevelopment,
VersionLabel: "0.1.3", VersionState: governance.VersionStateVerified,
VersionEvidence: governance.VersionEvidence{Reason: "counted_commits_since_base_tag"}},
{CommitSHA: other, CommittedAt: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC),
Subject: "feat: 同号的另一条", RecordKind: governance.RecordKindDevelopment,
VersionLabel: "0.1.3", VersionState: governance.VersionStateVerified},
}}
config := Config{VersionHistory: store, VersionHistoryRepo: "WeknorA/knowledge-governance"}
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?kind=development&limit=2", nil), config)
if recorder.Code != http.StatusOK {
t.Fatalf("状态码 = %d", recorder.Code)
}
if store.lastKind != "development" || store.lastLimit != 2 {
t.Fatalf("查询参数没透传:kind=%s limit=%d", store.lastKind, store.lastLimit)
}
payload := decodeVersionHistoryBody(t, recorder)
items, _ := payload["items"].([]any)
if len(items) != 2 {
t.Fatalf("记录数 = %d", len(items))
}
flagged := 0
for _, raw := range items {
item, _ := raw.(map[string]any)
if item["is_current_running"] == true {
flagged++
if item["commit_sha"] != sha {
t.Fatalf("只允许精确匹配的那条被标记:%v", item)
}
}
// 同号不同 SHA:两条都在,且都带自己的短 SHA
if item["version_label"] != "0.1.3" || item["short_sha"] == "" {
t.Fatalf("同号的记录应各自保留并以短 SHA 区分:%v", item)
}
}
if flagged != 1 {
t.Fatalf("被标记为当前运行的条数 = %d", flagged)
}
if payload["has_more"] != true || payload["next_cursor"] != "cursor-1" {
t.Fatalf("分页游标没透传:%v", payload)
}
running, _ := payload["current_running"].(map[string]any)
if running["state"] != "matched" || running["matched_commit_sha"] != sha {
t.Fatalf("当前运行判定不对:%v", running)
}
}
func TestVersionHistoryListDevelopmentBuildNotInRecordsIsNotMarked(t *testing.T) {
withInjectedIdentity(t, strings.Repeat("d", 40), "development")
store := &fakeVersionHistoryStore{snapshot: 3, items: []governance.VersionHistoryCommit{
{CommitSHA: strings.Repeat("e", 40), VersionLabel: "0.1.5", RecordKind: governance.RecordKindDevelopment}}}
config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"}
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), config)
payload := decodeVersionHistoryBody(t, recorder)
items, _ := payload["items"].([]any)
for _, raw := range items {
item, _ := raw.(map[string]any)
if item["is_current_running"] == true {
t.Fatalf("产物 SHA 不在记录里就不得标记任何条目:%v", item)
}
}
running, _ := payload["current_running"].(map[string]any)
if running["state"] != "development" || running["commit_sha"] == "" {
t.Fatalf("开发构建要显式标为开发构建,而不是标成某条记录:%v", running)
}
}
func TestVersionHistoryListRejectsBadLimitAndExpiredCursor(t *testing.T) {
withInjectedIdentity(t, "unknown", "development")
store := &fakeVersionHistoryStore{snapshot: 5}
config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"}
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?limit=999", nil), config)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("limit 越界应 400,得到 %d", recorder.Code)
}
if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "invalid_limit" {
t.Fatalf("错误码 = %v", code)
}
store.pageErr = postgresstore.ErrVersionHistoryCursorExpired
recorder = httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?cursor=stale", nil), config)
if recorder.Code != http.StatusConflict {
t.Fatalf("游标过期应 409,得到 %d", recorder.Code)
}
if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "cursor_expired" {
t.Fatalf("错误码 = %v", code)
}
}
func TestVersionHistorySyncRequiresAdminAndEnqueuesOnce(t *testing.T) {
// 角色:读任何角色可看,同步只能管理员
if got := classifyRole(http.MethodGet, "/api/v1/version-history"); got != roleViewer {
t.Fatalf("GET 的最小角色 = %q", got)
}
if got := classifyRole(http.MethodPost, "/api/v1/version-history/sync"); got != roleAdmin {
t.Fatalf("同步的最小角色 = %q,必须是 %q", got, roleAdmin)
}
now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
queue := &fakeJobStore{enqueueEchoRequestedID: true}
store := &fakeVersionHistoryStore{snapshot: 9}
config := Config{Store: queue, VersionHistory: store, VersionHistoryRepo: "Weknora/knowledge-governance",
Now: func() time.Time { return now }}
recorder := httptest.NewRecorder()
versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), config)
if recorder.Code != http.StatusAccepted {
t.Fatalf("首次同步应 202,得到 %d(%s)", recorder.Code, recorder.Body.String())
}
if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance:"+queue.enqueued.ID {
t.Fatalf("入队参数不对:%+v", queue.enqueued)
}
if queue.enqueued.MaxAttempts != 3 || queue.enqueued.AvailableAt != now {
t.Fatalf("入队参数不对:%+v", queue.enqueued)
}
payload := decodeVersionHistoryBody(t, recorder)
if payload["reused"] != false {
t.Fatalf("首次不该标成复用:%v", payload)
}
// 已经在跑:不重复入队,明确返回已有任务
store.inFlight = true
store.state = governance.VersionHistorySyncRun{ID: 11, JobID: "job-running", Status: "running"}
queue.enqueued = jobs.EnqueueRequest{}
recorder = httptest.NewRecorder()
versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), config)
if recorder.Code != http.StatusOK {
t.Fatalf("并发同步应 200 + 复用已有任务,得到 %d", recorder.Code)
}
payload = decodeVersionHistoryBody(t, recorder)
if payload["reused"] != true || payload["job_id"] != "job-running" {
t.Fatalf("应复用已有任务:%v", payload)
}
if queue.enqueued.Type != "" {
t.Fatalf("已在跑时不得再次入队:%+v", queue.enqueued)
}
}
func TestVersionHistorySyncUnavailableWhenNotWired(t *testing.T) {
recorder := httptest.NewRecorder()
versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), Config{})
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("未接线应 503,得到 %d", recorder.Code)
}
if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "version_history_unavailable" {
t.Fatalf("错误码 = %v", code)
}
// 未接线时页面仍然可读(只是没有记录)
store := &fakeVersionHistoryStore{stateErr: errors.New("boom")}
recorder = httptest.NewRecorder()
config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"}
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), config)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("查询异常应 500,得到 %d", recorder.Code)
}
}