013ce4a24e
build / build (push) Successful in 1m3s
收录 main、注释开发快照和完整运行 SHA;开发说明绑定原提交,补记 0.1.1–0.1.25。 进行中同步按仓库合并,完成后重新执行;启动入队,部署脚本读回版本证据。 开发快照独立于正式标签与 Release,既有冲突标签不移动。 已运行:整仓 make verify,Go 673 通过、前端 274 通过,92 个已声明可选跳过,必需跳过 0。 隔离 PostgreSQL 重复同步队列用例及 -race 通过;未更新现有 8088、未修改生产问答。
287 lines
12 KiB
Go
287 lines
12 KiB
Go
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)
|
||
}
|
||
}
|