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) } }