package server import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "testing" "time" "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.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } st, err := srv.svc.DocRevision().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.svc.DocRevision().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) } } 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.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 99, "other", 2, "r", 3, "a"); err != nil { t.Fatal(err) } st, err := srv.svc.DocRevision().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.svc.DocRevision().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.svc.DocGov().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.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } st, err := srv.svc.DocRevision().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.svc.DocGov().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) } }