Files
Agent-System/internal/docrevision/reconcile_test.go
T
WeiCHEN 9c7a5be209 feat(arch): clear G1 U3/U4 sql_write and path exceptions
Move document_records duty-slot writes into docgov, project ledger
heads through docgov/freedoc ports, and workspace deliverable I/O
through DeliverableStore so arch-check baseline drops to sql_write:1
path:13.
2026-09-22 21:40:12 +08:00

361 lines
11 KiB
Go

package docrevision_test
import (
"context"
"os"
"path/filepath"
"testing"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/fsutil"
"chat-agent-system/internal/workspace"
)
func project143Raw() string {
return "条目一 \n条目二 \n条目三 \n条目四 \n条目五 \n结尾段落。\n"
}
func TestReconcile_DryRunDoesNotMutateLegacyDrift(t *testing.T) {
ctx := t.Context()
dir := t.TempDir()
workDir := filepath.Join(dir, "ws143")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
raw := project143Raw()
legacy := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
eng := docgov.NewGovernanceEngine(nil)
if _, err := eng.CreateOrGetDocument(ctx, "doc_proj143_intake", 143, "intake", "收集表", "V0.1", workDir, 9, "a", 2, "r", 3, "p"); err != nil {
t.Fatal(err)
}
_ = eng.MarkDocumentContentUpdated(ctx, "doc_proj143_intake", docrevision.SHA256Hex(legacy), false)
svc := docrevision.NewService(nil)
svc.DataDir = dir
st, err := svc.OpenTemplateVersion(ctx, "doc_proj143_intake", legacy, docrevision.ActorSnapshot{DisplayName: "bootstrap"}, 143, workDir)
if err != nil {
t.Fatal(err)
}
oldHead := st.HeadSnapshotID
oldSHA := st.HeadSHA256
res, err := svc.ReconcileDocument(ctx, docrevision.ReconcileRequest{
DocID: "doc_proj143_intake", Apply: false, BackupDir: filepath.Join(dir, "backup"),
})
if err != nil {
t.Fatal(err)
}
if res.Class != docrevision.ProjectionLegacyCanonicalizationDrift {
t.Fatalf("class=%s", res.Class)
}
if res.Applied {
t.Fatal("dry-run must not apply")
}
st2, err := svc.GetRevisionState(ctx, "doc_proj143_intake")
if err != nil {
t.Fatal(err)
}
if st2.HeadSnapshotID != oldHead || st2.HeadSHA256 != oldSHA {
t.Fatal("dry-run must not move head")
}
got, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if string(got) != raw {
t.Fatalf("dry-run must not touch file")
}
doc, _ := eng.GetDocument(ctx, "doc_proj143_intake")
if doc.ContentSHA256 != oldSHA {
t.Fatal("dry-run must not rewrite record hash")
}
}
func TestReconcile_ApplyLegacyRepairKeepsFileAndMovesHead(t *testing.T) {
ctx := t.Context()
dir := t.TempDir()
workDir := filepath.Join(dir, "ws143")
backup := filepath.Join(dir, "backup")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
raw := project143Raw()
legacy := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
eng := docgov.NewGovernanceEngine(nil)
if _, err := eng.CreateOrGetDocument(ctx, "doc_proj143_intake", 143, "intake", "收集表", "V0.1", workDir, 9, "a", 2, "r", 3, "p"); err != nil {
t.Fatal(err)
}
_ = eng.MarkDocumentContentUpdated(ctx, "doc_proj143_intake", docrevision.SHA256Hex(legacy), false)
svc := docrevision.NewService(nil)
svc.DataDir = dir
st, err := svc.OpenTemplateVersion(ctx, "doc_proj143_intake", legacy, docrevision.ActorSnapshot{DisplayName: "bootstrap"}, 143, workDir)
if err != nil {
t.Fatal(err)
}
oldHead := st.HeadSnapshotID
oldRev := st.HeadRevisionNo
oldHuman := st.ManualChangeCount
oldETag := st.ETag
res, err := svc.ReconcileDocument(ctx, docrevision.ReconcileRequest{
DocID: "doc_proj143_intake", Apply: true, BackupDir: backup, HashUpdater: eng,
})
if err != nil {
t.Fatal(err)
}
if !res.Applied || res.Class != docrevision.ProjectionLegacyCanonicalizationDrift {
t.Fatalf("apply result %+v", res)
}
if res.ChangeKind != docrevision.ChangeMigrationRepair {
t.Fatalf("change kind %q", res.ChangeKind)
}
got, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if string(got) != raw {
t.Fatalf("apply must keep original file, got %q", got)
}
st2, err := svc.GetRevisionState(ctx, "doc_proj143_intake")
if err != nil {
t.Fatal(err)
}
if st2.HeadSnapshotID == oldHead {
t.Fatal("must not keep snapshot-66-equivalent head; need new migration_repair snapshot")
}
if st2.HeadRevisionNo != oldRev {
t.Fatalf("migration_repair must keep head_revision_no %d, got %d", oldRev, st2.HeadRevisionNo)
}
if st2.ManualChangeCount != oldHuman {
t.Fatalf("migration_repair must keep human_revision_count %d, got %d", oldHuman, st2.ManualChangeCount)
}
if st2.ETag == oldETag {
t.Fatal("new head snapshot id must change ETag")
}
canon := docrevision.CanonicalizeMarkdown(raw)
if st2.HeadSHA256 != docrevision.SHA256Hex(canon) {
t.Fatalf("head sha %s want %s", st2.HeadSHA256, docrevision.SHA256Hex(canon))
}
headMD, headSHA, err := svc.HeadContent(ctx, "doc_proj143_intake")
if err != nil {
t.Fatal(err)
}
if headMD != canon || headSHA != st2.HeadSHA256 {
t.Fatalf("head content must be preserved-hard-break canon")
}
doc, _ := eng.GetDocument(ctx, "doc_proj143_intake")
if doc.ContentSHA256 != st2.HeadSHA256 {
t.Fatalf("record sha %s != head %s", doc.ContentSHA256, st2.HeadSHA256)
}
if res.BackupDir == "" || res.BackupFile == "" || res.BackupHeadFile == "" || res.BackupMetaFile == "" {
t.Fatalf("result must report all backup paths: %+v", res)
}
gotOut, err := os.ReadFile(res.BackupFile)
if err != nil {
t.Fatalf("original output backup missing: %v", err)
}
if string(gotOut) != raw {
t.Fatalf("output.md backup %q", gotOut)
}
headBackup, err := os.ReadFile(res.BackupHeadFile)
if err != nil {
t.Fatalf("old head markdown backup missing: %v", err)
}
if string(headBackup) != legacy {
t.Fatalf("head.md must be old destructive head, got %q", headBackup)
}
if _, err := os.Stat(res.BackupMetaFile); err != nil {
t.Fatalf("backup metadata missing: %v", err)
}
}
func TestBackupReconcileArtifacts_RepeatKeepsFirstAndReportsAllPaths(t *testing.T) {
dir := t.TempDir()
raw := []byte(project143Raw())
head := docrevision.LegacyDestructiveCanonicalizeMarkdown(string(raw))
first, err := docrevision.BackupReconcileArtifacts(dir, "doc_x", raw, 66, "sha-old", head)
if err != nil {
t.Fatal(err)
}
second, err := docrevision.BackupReconcileArtifacts(dir, "doc_x", []byte("第二次不应覆盖\n"), 67, "sha-new", "new-head\n")
if err != nil {
t.Fatal(err)
}
if first.Dir == second.Dir {
t.Fatal("repeat run must use a unique backup directory")
}
got, err := os.ReadFile(first.OutputFile)
if err != nil {
t.Fatal(err)
}
if string(got) != string(raw) {
t.Fatalf("first output backup overwritten: %q", got)
}
gotHead, _ := os.ReadFile(first.HeadFile)
if string(gotHead) != head {
t.Fatalf("first head backup overwritten: %q", gotHead)
}
if _, err := os.Stat(first.MetaFile); err != nil {
t.Fatal(err)
}
if err := fsutil.WriteFileAtomicExclusive(first.OutputFile, []byte("nope\n"), 0o644); err == nil {
t.Fatal("O_EXCL must refuse rewriting an existing backup file")
}
}
func TestBackupReconcileArtifacts_ExclusiveDirCollision(t *testing.T) {
dir := t.TempDir()
blocked := filepath.Join(dir, "doc_x-1")
if err := os.Mkdir(blocked, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(blocked, "output.md")
if err := fsutil.WriteFileAtomicExclusive(path, []byte("keep\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := fsutil.WriteFileAtomicExclusive(path, []byte("nope\n"), 0o644); err == nil {
t.Fatal("existing exclusive backup file must not be overwritten")
}
got, _ := os.ReadFile(path)
if string(got) != "keep\n" {
t.Fatalf("mutated %q", got)
}
}
func TestReconcile_ApplyRefusesContentFork(t *testing.T) {
ctx := t.Context()
dir := t.TempDir()
workDir := filepath.Join(dir, "ws")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte("真实分叉正文\n"), 0o644); err != nil {
t.Fatal(err)
}
svc := docrevision.NewService(nil)
svc.DataDir = dir
st, err := svc.OpenTemplateVersion(ctx, "doc_fork", "# 账本\n", docrevision.ActorSnapshot{}, 1, workDir)
if err != nil {
t.Fatal(err)
}
_, err = svc.ReconcileDocument(ctx, docrevision.ReconcileRequest{DocID: "doc_fork", Apply: true, BackupDir: filepath.Join(dir, "b")})
if err == nil {
t.Fatal("fork apply must fail closed")
}
st2, _ := svc.GetRevisionState(ctx, "doc_fork")
if st2.HeadSHA256 != st.HeadSHA256 {
t.Fatal("fork must not move head")
}
got, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if string(got) != "真实分叉正文\n" {
t.Fatal("fork must not overwrite file")
}
}
func TestWritePaths_HumanSaveTrailingSpacesShareBytes(t *testing.T) {
ctx := t.Context()
dir := t.TempDir()
workDir := filepath.Join(dir, "ws")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
svc := docrevision.NewService(nil)
svc.DataDir = dir
svc.SetDeliverableStore(workspace.DirDeliverable{})
st, err := svc.OpenTemplateVersion(ctx, "doc_human", "# A\n", docrevision.ActorSnapshot{}, 1, workDir)
if err != nil {
t.Fatal(err)
}
md := "# A\n\n人工 \n改动\n"
r, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: "doc_human", Markdown: md,
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
IdempotencyKey: "h1", ActorUserID: 9, LifecycleState: "drafting", WorkDir: workDir,
})
if err != nil {
t.Fatal(err)
}
if err := svc.ProjectOutputMarkdown("doc_human", dir); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
canon := docrevision.CanonicalizeMarkdown(md)
if string(got) != canon {
t.Fatalf("file %q != canon %q", got, canon)
}
if r.State.HeadSHA256 != docrevision.SHA256Hex(string(got)) {
t.Fatalf("head/file sha diverge")
}
}
type recordingUpdater struct {
calls int
sha string
err error
}
func (r *recordingUpdater) MarkDocumentContentUpdated(_ context.Context, _, contentSHA256 string, _ bool) error {
r.calls++
r.sha = contentSHA256
return r.err
}
func TestCommitMigrationRepair_RetryStillSyncsHashUpdater(t *testing.T) {
ctx := t.Context()
dir := t.TempDir()
workDir := filepath.Join(dir, "ws")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
raw := project143Raw()
legacy := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
svc := docrevision.NewService(nil)
svc.DataDir = dir
st, err := svc.OpenTemplateVersion(ctx, "doc_retry", legacy, docrevision.ActorSnapshot{}, 1, workDir)
if err != nil {
t.Fatal(err)
}
failing := &recordingUpdater{err: errUpdaterBoom}
_, err = svc.CommitMigrationRepair(ctx, docrevision.MigrationRepairRequest{
DocID: "doc_retry", Markdown: raw, HashUpdater: failing,
})
if err == nil {
t.Fatal("first updater failure must surface")
}
st2, err := svc.GetRevisionState(ctx, "doc_retry")
if err != nil {
t.Fatal(err)
}
if st2.HeadSnapshotID == st.HeadSnapshotID {
t.Fatal("ledger head already moved before updater failure")
}
ok := &recordingUpdater{}
st3, err := svc.CommitMigrationRepair(ctx, docrevision.MigrationRepairRequest{
DocID: "doc_retry", Markdown: raw, HashUpdater: ok,
})
if err != nil {
t.Fatal(err)
}
if ok.calls != 1 {
t.Fatalf("retry must still sync HashUpdater, calls=%d", ok.calls)
}
if ok.sha != st3.HeadSHA256 {
t.Fatalf("retry sha %s want %s", ok.sha, st3.HeadSHA256)
}
if st3.HeadRevisionNo != st.HeadRevisionNo || st3.ManualChangeCount != st.ManualChangeCount {
t.Fatalf("retry must not increment revision counters: %+v", st3)
}
}
var errUpdaterBoom = errString("updater boom")
type errString string
func (e errString) Error() string { return string(e) }