4cb5110845
build / build (push) Successful in 1m1s
现象(主人反馈):点「查看提交」打开是空白页。原因:链接用的是**抓取用的内网地址** (`http://192.168.50.223:3000/...`),内网外的浏览器到不了;而公网地址 `https://gitea.zheromiku.top/...` 实测 200。另外 tag 证据里**根本没有 url**, 页面渲染出空 `href`,点下去像"没反应"。 修复: - 新增 `VERSION_HISTORY_LINK_ORIGIN`(页面链接 origin,公网;留空 = 与抓取 origin 相同): 抓取仍匿名走内网白名单地址,**给人点的链接**用公网 origin 拼 —— 两者本就不该共用一个地址; 校验必须是干净的 http(s) 绝对地址。 - tag 证据补 `url`(`<link origin>/<repo>/src/tag/<name>`),并在前端**没有链接就不渲染**该按钮。 - 测试:客户端(LinkOrigin 生效 / 未配时退回 / 非法拒绝)、规则(tag 链接写入证据 / 无基址不编链接)、 前端(有链接才渲染「查看 tag」)。 验证:make verify 15/15;真实链路重同步后库里 commit_url = `https://gitea.zheromiku.top/...`(200)、 release_tags 带 url。
256 lines
8.4 KiB
Go
256 lines
8.4 KiB
Go
package versionhistory
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"knowledge-governance/internal/governance"
|
||
)
|
||
|
||
type fakeSource struct {
|
||
commits []RawCommit
|
||
total int
|
||
pageSize int
|
||
tags []RawTag
|
||
changelog map[string]string
|
||
head string
|
||
headErr error
|
||
commitsErr error
|
||
listCalls int
|
||
}
|
||
|
||
func (f *fakeSource) PageSize() int { return f.pageSize }
|
||
func (f *fakeSource) CommitURLBase() string {
|
||
return "http://gitea.test/Weknora/knowledge-governance/commit/"
|
||
}
|
||
|
||
func (f *fakeSource) TagURLBase() string {
|
||
return "http://gitea.test/Weknora/knowledge-governance/src/tag/"
|
||
}
|
||
|
||
func (f *fakeSource) ListCommits(_ context.Context, page, limit int) ([]RawCommit, int, error) {
|
||
f.listCalls++
|
||
if f.commitsErr != nil {
|
||
return nil, 0, f.commitsErr
|
||
}
|
||
start := (page - 1) * limit
|
||
if start >= len(f.commits) {
|
||
return nil, f.total, nil
|
||
}
|
||
end := start + limit
|
||
if end > len(f.commits) {
|
||
end = len(f.commits)
|
||
}
|
||
return f.commits[start:end], f.total, nil
|
||
}
|
||
|
||
func (f *fakeSource) ListTags(context.Context) ([]RawTag, error) { return f.tags, nil }
|
||
|
||
func (f *fakeSource) ReadFile(_ context.Context, sha, _ string) (string, error) {
|
||
if text, ok := f.changelog[sha]; ok {
|
||
return text, nil
|
||
}
|
||
return "", fmt.Errorf("no changelog for %s", sha)
|
||
}
|
||
|
||
func (f *fakeSource) BranchHeadSHA(context.Context, string) (string, error) {
|
||
if f.headErr != nil {
|
||
return "", f.headErr
|
||
}
|
||
return f.head, nil
|
||
}
|
||
|
||
type savedSnapshot struct {
|
||
snapshotID int64
|
||
records []governance.VersionHistoryCommit
|
||
expected int
|
||
complete bool
|
||
}
|
||
|
||
type fakeStore struct {
|
||
beginCalls int
|
||
beginErr error
|
||
saved []savedSnapshot
|
||
finished []SyncOutcome
|
||
failed []string
|
||
activeSnapshot int64
|
||
lastSuccess time.Time
|
||
saveErr error
|
||
}
|
||
|
||
func (f *fakeStore) BeginVersionHistorySync(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
|
||
f.beginCalls++
|
||
if f.beginErr != nil {
|
||
return 0, f.beginErr
|
||
}
|
||
return int64(f.beginCalls), nil
|
||
}
|
||
|
||
func (f *fakeStore) SaveVersionHistorySnapshot(_ context.Context, snapshotID int64, _ string,
|
||
records []governance.VersionHistoryCommit, expected int, complete bool, _ time.Time) error {
|
||
if f.saveErr != nil {
|
||
return f.saveErr
|
||
}
|
||
f.saved = append(f.saved, savedSnapshot{snapshotID: snapshotID, records: records,
|
||
expected: expected, complete: complete})
|
||
if complete {
|
||
f.activeSnapshot = snapshotID
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeStore) FinishVersionHistorySync(_ context.Context, _ int64, outcome SyncOutcome, now time.Time) error {
|
||
f.finished = append(f.finished, outcome)
|
||
f.lastSuccess = now
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeStore) FailVersionHistorySync(_ context.Context, _ int64, code string, _ time.Time) error {
|
||
f.failed = append(f.failed, code)
|
||
return nil
|
||
}
|
||
|
||
func linearCommits(count int) []RawCommit {
|
||
commits := make([]RawCommit, 0, count)
|
||
for index := 0; index < count; index++ {
|
||
sha := fmt.Sprintf("%040x", index+1)
|
||
parents := []string{}
|
||
if index > 0 {
|
||
parents = append(parents, fmt.Sprintf("%040x", index))
|
||
}
|
||
commits = append(commits, RawCommit{SHA: sha, Parents: parents,
|
||
CommittedAt: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC).Add(time.Duration(index) * time.Minute),
|
||
AuthorDisplay: "姚章浩", Subject: fmt.Sprintf("feat: 第 %d 条", index)})
|
||
}
|
||
return commits
|
||
}
|
||
|
||
func newTestSyncer(t *testing.T, source Source, store SyncStore) *Syncer {
|
||
t.Helper()
|
||
syncer, err := NewSyncer(SyncerConfig{Source: source, Store: store, RepoKey: "Weknora/knowledge-governance",
|
||
Now: func() time.Time { return time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) }})
|
||
if err != nil {
|
||
t.Fatalf("装配同步器:%v", err)
|
||
}
|
||
return syncer
|
||
}
|
||
|
||
func TestSyncPullsEveryPageAndReconcilesTotal(t *testing.T) {
|
||
commits := linearCommits(111)
|
||
source := &fakeSource{commits: commits, total: 111, pageSize: 50, head: commits[110].SHA,
|
||
changelog: map[string]string{}}
|
||
store := &fakeStore{}
|
||
run, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-1", "admin")
|
||
if err != nil {
|
||
t.Fatalf("同步失败:%v", err)
|
||
}
|
||
if source.listCalls != 3 {
|
||
t.Fatalf("应抓 3 页,实际 %d 次", source.listCalls)
|
||
}
|
||
if len(store.saved) != 1 || len(store.saved[0].records) != 111 {
|
||
t.Fatalf("落库记录数 = %d", len(store.saved[0].records))
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, record := range store.saved[0].records {
|
||
if seen[record.CommitSHA] {
|
||
t.Fatalf("落库出现重复提交 %s", record.CommitSHA)
|
||
}
|
||
seen[record.CommitSHA] = true
|
||
}
|
||
if !run.Complete || run.FetchedCount != 111 || run.SourceHeadSHA != commits[110].SHA {
|
||
t.Fatalf("同步结果不完整:%+v", run)
|
||
}
|
||
if store.activeSnapshot != 1 || len(store.finished) != 1 || len(store.failed) != 0 {
|
||
t.Fatalf("快照切换/完成记录不对:%+v", store)
|
||
}
|
||
}
|
||
|
||
func TestSyncFailsLoudlyWhenCountDoesNotMatch(t *testing.T) {
|
||
commits := linearCommits(100) // 远端宣称 121,实际只给 100
|
||
source := &fakeSource{commits: commits, total: 121, pageSize: 50, head: commits[99].SHA}
|
||
store := &fakeStore{activeSnapshot: 7}
|
||
_, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-2", "admin")
|
||
if err == nil {
|
||
t.Fatal("数量不一致必须报错,不能宣称完整")
|
||
}
|
||
if len(store.failed) != 1 || store.failed[0] != ErrorCodeCountMismatch {
|
||
t.Fatalf("错误码 = %v,期望 %s", store.failed, ErrorCodeCountMismatch)
|
||
}
|
||
if len(store.saved) != 0 || store.activeSnapshot != 7 {
|
||
t.Fatalf("失败时不得写快照、不得切换对外快照:%+v", store)
|
||
}
|
||
}
|
||
|
||
func TestSyncKeepsPreviousSnapshotWhenGiteaIsUnreachable(t *testing.T) {
|
||
source := &fakeSource{pageSize: 50, commitsErr: newGiteaError(KindUnreachable, 0, "不可达", nil)}
|
||
store := &fakeStore{activeSnapshot: 3, lastSuccess: time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)}
|
||
_, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-3", "admin")
|
||
if err == nil {
|
||
t.Fatal("不可达必须报错")
|
||
}
|
||
if len(store.failed) != 1 || store.failed[0] != ErrorCodeUnreachable {
|
||
t.Fatalf("错误码 = %v", store.failed)
|
||
}
|
||
if store.activeSnapshot != 3 || !store.lastSuccess.Equal(time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)) {
|
||
t.Fatalf("失败不得动既有快照与最后成功时间:%+v", store)
|
||
}
|
||
}
|
||
|
||
func TestSyncIsIdempotentAndPicksUpTagsAddedAfterwards(t *testing.T) {
|
||
commits := linearCommits(3)
|
||
changelog := map[string]string{}
|
||
for _, commit := range commits {
|
||
changelog[commit.SHA] = "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 新\n\n## [0.1.0] - 2026-10-01\n\n### Added\n\n- 首次发布\n"
|
||
}
|
||
source := &fakeSource{commits: commits, total: 3, pageSize: 50, head: commits[2].SHA, changelog: changelog}
|
||
store := &fakeStore{}
|
||
syncer := newTestSyncer(t, source, store)
|
||
if _, err := syncer.Sync(context.Background(), "job-4", "admin"); err != nil {
|
||
t.Fatalf("首次同步:%v", err)
|
||
}
|
||
first := store.saved[0].records
|
||
if len(first) != 3 {
|
||
t.Fatalf("记录数 = %d", len(first))
|
||
}
|
||
// 还没有 tag ⇒ 全都是历史提交(不给号)
|
||
for _, record := range first {
|
||
if record.VersionLabel != "" || record.RecordKind != governance.RecordKindHistorical {
|
||
t.Fatalf("无 tag 时不应给号:%+v", record)
|
||
}
|
||
}
|
||
// 旧提交后补一个注释 tag ⇒ 再同步不新增记录,只把 A 升级成正式版本,B/C 拿到开发号
|
||
source.tags = []RawTag{{Name: "v0.1.0", TagSHA: strings.Repeat("a", 40), TargetSHA: commits[0].SHA, Annotated: true}}
|
||
if _, err := syncer.Sync(context.Background(), "job-5", "admin"); err != nil {
|
||
t.Fatalf("二次同步:%v", err)
|
||
}
|
||
second := store.saved[1].records
|
||
if len(second) != len(first) {
|
||
t.Fatalf("后补 tag 不得新增记录:%d → %d", len(first), len(second))
|
||
}
|
||
bySHA := map[string]governance.VersionHistoryCommit{}
|
||
for _, record := range second {
|
||
bySHA[record.CommitSHA] = record
|
||
}
|
||
if got := bySHA[commits[0].SHA]; got.RecordKind != governance.RecordKindRelease || got.VersionLabel != "0.1.0" {
|
||
t.Fatalf("tag 指向的提交应升级为正式版本:%+v", got)
|
||
}
|
||
if got := bySHA[commits[1].SHA]; got.VersionLabel != "0.1.1" || got.RecordKind != governance.RecordKindDevelopment {
|
||
t.Fatalf("tag 之后的提交应拿到开发号:%+v", got)
|
||
}
|
||
if got := bySHA[commits[2].SHA]; got.VersionLabel != "0.1.2" {
|
||
t.Fatalf("第二个提交应是 0.1.2:%+v", got)
|
||
}
|
||
}
|
||
|
||
func TestSyncerRequiresDependencies(t *testing.T) {
|
||
if _, err := NewSyncer(SyncerConfig{RepoKey: "a/b"}); err == nil {
|
||
t.Fatal("缺数据源/存储必须拒绝装配")
|
||
}
|
||
if _, err := NewSyncer(SyncerConfig{Source: &fakeSource{}, Store: &fakeStore{}}); err == nil {
|
||
t.Fatal("缺仓库标识必须拒绝装配")
|
||
}
|
||
}
|