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.
361 lines
11 KiB
Go
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) }
|