Files
Agent-System/internal/server/document_revision_patch_order_test.go
weichenandCursor 952a94b6d5 fix(docrevision): ack content projection only after the fact source is written
The human ledger and the document body are different stores. Record the projection intent in the same transaction as the ledger, and clear it only when the body write succeeds so a retry cannot append a second revision.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 09:35:14 +00:00

298 lines
11 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
)
func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
dataRoot := srv.dataRoot()
// WorkDir whose parent is a regular file → SaveDocumentContent MkdirAll fails after ledger commit.
blocker := filepath.Join(dataRoot, "proj-block")
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
workDir := filepath.Join(blocker, "ws-proj")
docID := "doc_proj1_patch_order"
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
beforeRev := st.HeadRevisionNo
beforeSnap := st.HeadSnapshotID
token := loginTestAdmin(t, srv)
body := `{"markdown":"# base\n\nbody\n\nedited by human\n"}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(body)))
withBearerAuth(req, token)
req.Header.Set("Idempotency-Key", "patch-order-k1")
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("projection failure want 500 got %d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["code"] != "DOCUMENT_PROJECTION_FAILED" {
t.Fatalf("code want DOCUMENT_PROJECTION_FAILED got %#v", payload["code"])
}
after, err := srv.rev.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if after.HeadRevisionNo <= beforeRev && after.HeadSnapshotID == beforeSnap {
t.Fatalf("ledger should commit before projection; before rev=%d snap=%d after=%+v", beforeRev, beforeSnap, after)
}
assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID)
}
func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
docID := "doc_proj1_lease_denied"
workDir := filepath.Join(srv.dataRoot(), "ws-lease")
_ = os.MkdirAll(workDir, 0o755)
// Author is user 99; login admin may bypass route author gate but AssertHumanCanEdit (RoleAuthor) still fails.
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 99, "other", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
before := st.HeadRevisionNo
token := loginTestAdmin(t, srv)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
bytes.NewReader([]byte(`{"markdown":"# L\n\nchanged\n"}`)))
withBearerAuth(req, token)
req.Header.Set("Idempotency-Key", "lease-denied-k1")
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Fatalf("non-author must not save successfully: %s", w.Body.String())
}
after, err := srv.rev.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if after.HeadRevisionNo != before {
t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo)
}
doc, err := srv.gov.GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "1" {
t.Fatal("failed AssertHumanCanEdit must not leave non-author admin holding the lease")
}
}
func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
workDir := filepath.Join(srv.dataRoot(), "ws-audit")
_ = os.MkdirAll(workDir, 0o755)
docID := "doc_proj1_patch_audit"
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
token := loginTestAdmin(t, srv)
md := "# base\n\nbody\n\nhuman edit\n"
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
bytes.NewReader([]byte(`{"markdown":`+strconv.Quote(md)+`}`)))
withBearerAuth(req, token)
req.Header.Set("Idempotency-Key", "patch-audit-k1")
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200 got %d %s", w.Code, w.Body.String())
}
doc, err := srv.gov.GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.AuthorLastEditedAt == nil {
t.Fatal("SaveDocumentContent projection must set author_last_edited_at")
}
if !doc.AuthorClaimed {
t.Fatal("author_claimed should be true after human save projection")
}
if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil {
t.Fatalf("output.md should exist after SaveDocumentContent: %v", err)
}
assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID)
}
func TestDocRevision_PatchReleasesHumanLeaseOnHeadConflict(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
workDir := filepath.Join(srv.dataRoot(), "ws-conflict-lease")
_ = os.MkdirAll(workDir, 0o755)
docID := "doc_proj1_patch_conflict_lease"
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
token := loginTestAdmin(t, srv)
etag := `"` + strconv.FormatInt(st.HeadSnapshotID, 10) + ":" + strconv.Itoa(st.HeadRevisionNo) + `"`
first := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA1\n"}`)))
withBearerAuth(first, token)
first.Header.Set("Idempotency-Key", "conflict-lease-a1")
first.Header.Set("If-Match", etag)
w1 := httptest.NewRecorder()
srv.Routes().ServeHTTP(w1, first)
if w1.Code != http.StatusOK {
t.Fatalf("first save want 200 got %d %s", w1.Code, w1.Body.String())
}
second := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA2\n"}`)))
withBearerAuth(second, token)
second.Header.Set("Idempotency-Key", "conflict-lease-a2")
second.Header.Set("If-Match", etag)
w2 := httptest.NewRecorder()
srv.Routes().ServeHTTP(w2, second)
if w2.Code != http.StatusConflict {
t.Fatalf("stale etag want 409 got %d %s", w2.Code, w2.Body.String())
}
assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID)
}
func assertHumanLeaseReleasedSquadCanAcquire(t *testing.T, srv *Server, docID string) {
t.Helper()
ctx := context.Background()
doc, err := srv.gov.GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.HasActiveLease(time.Now()) {
t.Fatalf("human lease must be released after PATCH, holder=%s/%s", doc.LeaseHolderType, doc.LeaseHolderID)
}
leased, err := srv.gov.AcquireLease(ctx, docgov.LeaseAcquireRequest{
DocID: docID,
HolderType: docgov.LeaseHolderSquad,
HolderID: "run_after_human_save",
TTL: time.Minute,
})
if err != nil {
t.Fatalf("squad must acquire after human PATCH released lease: %v", err)
}
if leased.LeaseHolderType != docgov.LeaseHolderSquad {
t.Fatalf("want squad holder, got %s", leased.LeaseHolderType)
}
if _, err := srv.gov.ReleaseLease(ctx, docID, string(docgov.LeaseHolderSquad), "run_after_human_save"); err != nil {
t.Fatal(err)
}
}
func TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
dataRoot := srv.dataRoot()
blocker := filepath.Join(dataRoot, "proj-retry-block")
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
workDir := filepath.Join(blocker, "ws-retry")
docID := "doc_proj1_patch_retry"
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
token := loginTestAdmin(t, srv)
body := `{"markdown":"# base\n\nbody\n\nedited by human\n"}`
patch := func() *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(body)))
withBearerAuth(req, token)
req.Header.Set("Idempotency-Key", "patch-retry-k1")
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
return w
}
first := patch()
if first.Code != http.StatusInternalServerError {
t.Fatalf("first projection want 500 got %d %s", first.Code, first.Body.String())
}
pending, ok := srv.rev.ContentProjectionPending(docID)
if !ok {
t.Fatal("ledger commit must leave a content projection pending")
}
afterFail, err := srv.rev.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if afterFail.HeadSnapshotID != pending.SnapshotID {
t.Fatalf("pending snapshot %d != ledger head %d", pending.SnapshotID, afterFail.HeadSnapshotID)
}
if err := os.Remove(blocker); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
second := patch()
if second.Code != http.StatusOK {
t.Fatalf("retry want 200 got %d %s", second.Code, second.Body.String())
}
afterRetry, err := srv.rev.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if afterRetry.HeadRevisionNo != afterFail.HeadRevisionNo || afterRetry.HeadSnapshotID != afterFail.HeadSnapshotID {
t.Fatalf("retry must not write a second revision: fail=%+v retry=%+v", afterFail, afterRetry)
}
if _, still := srv.rev.ContentProjectionPending(docID); still {
t.Fatal("successful projection must ack the pending row")
}
if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil {
t.Fatalf("retry must write output.md: %v", err)
}
}