Files
knowledge-governance/internal/versionhistory/sync_test.go
T
姚章浩 4cb5110845
build / build (push) Successful in 1m1s
fix(version-history): 页面链接改走公网 origin(抓取仍走内网)+ tag 补链接
现象(主人反馈):点「查看提交」打开是空白页。原因:链接用的是**抓取用的内网地址**
(`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。
2026-10-14 11:06:13 +08:00

256 lines
8.4 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 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("缺仓库标识必须拒绝装配")
}
}