Ctrl K
ComunidadGuías…

server/commands/documentUpdater.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { randomUUID } from "node:crypto";
import * as Y from "yjs";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import {
  TextEditMode,
  MentionType,
  type ProsemirrorData,
  type ProsemirrorMark,
} from "@shared/types";
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
import { Document, Event } from "@server/models";
import { parser, schema } from "@server/editor";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import {
  buildCollection,
  buildDocument,
  buildUser,
} from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import documentUpdater from "./documentUpdater";

describe("documentUpdater", () => {
  it.each(Object.values(TextEditMode))(
    "should attribute new mentions and preserve existing actors in %s mode",
    async (editMode) => {
      const user = await buildUser();
      const originalActor = await buildUser({ teamId: user.teamId });
      const mentionId = randomUUID();
      const originalMention = `@[Original](mention://${mentionId}/user/${originalActor.id})`;
      const initialDoc = ProsemirrorHelper.assignMentionActors(
        parser.parse(`${originalMention} replace me`),
        originalActor.id
      );
      let document = await buildDocument({
        teamId: user.teamId,
        content: initialDoc.toJSON(),
        state: Buffer.from(
          Y.encodeStateAsUpdate(ProsemirrorHelper.toYDoc(initialDoc.toJSON()))
        ),
      });
      const newMention = `@[Writer](mention://user/${user.id})`;

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          document,
          text:
            editMode === TextEditMode.Replace
              ? `${originalMention} ${newMention}`
              : newMention,
          editMode,
          findText: editMode === TextEditMode.Patch ? "replace me" : undefined,
        })
      );

      document = await Document.findByPk(document.id, {
        includeState: true,
        rejectOnEmpty: true,
      });
      expect(
        DocumentHelper.parseMentions(document, { type: MentionType.User })
      ).toEqual(
        expect.arrayContaining([
          expect.objectContaining({ id: mentionId, actorId: originalActor.id }),
          expect.objectContaining({ modelId: user.id, actorId: user.id }),
        ])
      );
      if (!document.state) {
        throw new Error("Expected collaborative state to be persisted");
      }
      const ydoc = new Y.Doc();
      Y.applyUpdate(ydoc, document.state);
      expect(yDocToProsemirrorJSON(ydoc, "default")).toEqual(document.content);
    }
  );

  it("should leave retained mentions without attribution unchanged", async () => {
    const user = await buildUser();
    const mention = `@[Writer](mention://${randomUUID()}/user/${user.id})`;
    let document = await buildDocument({
      teamId: user.teamId,
      content: parser.parse(mention).toJSON(),
    });
    const originalMentions = DocumentHelper.parseMentions(document);

    document = await withAPIContext(user, (ctx) =>
      documentUpdater(ctx, { document, text: `${mention} updated` })
    );

    await document.reload();
    expect(DocumentHelper.parseMentions(document)).toEqual(originalMentions);
  });

  it("should change lastModifiedById", async () => {
    const user = await buildUser();
    let document = await buildDocument({
      teamId: user.teamId,
    });

    document = await withAPIContext(user, (ctx) =>
      documentUpdater(ctx, {
        text: "Changed",
        document,
      })
    );

    const event = await Event.findLatest({
      teamId: user.teamId,
    });
    expect(document.lastModifiedById).toEqual(user.id);
    expect(event!.name).toEqual("documents.update");
    expect(event!.documentId).toEqual(document.id);
  });

  it("should change lastModifiedById when republishing an already published document", async () => {
    const user = await buildUser();
    const creator = await buildUser({ teamId: user.teamId });
    let document = await buildDocument({
      teamId: user.teamId,
      userId: creator.id,
    });

    document = await withAPIContext(user, (ctx) =>
      documentUpdater(ctx, {
        text: "Changed",
        publish: true,
        document,
      })
    );

    expect(document.createdById).toEqual(creator.id);
    expect(document.lastModifiedById).toEqual(user.id);
  });

  it("should not change lastModifiedById or generate event if nothing changed", async () => {
    const user = await buildUser();
    let document = await buildDocument({
      teamId: user.teamId,
    });

    document = await withAPIContext(user, (ctx) =>
      documentUpdater(ctx, {
        title: document.title,
        document,
      })
    );

    expect(document.lastModifiedById).not.toEqual(user.id);
  });

  it("should notify collaboration server when text changes", async () => {
    const notifyUpdateSpy = vi
      .spyOn(APIUpdateExtension, "notifyUpdate")
      .mockResolvedValue(undefined);

    const user = await buildUser();
    let document = await buildDocument({
      teamId: user.teamId,
      text: "Initial text",
    });

    // Create initial collaborative state (simulating an active collaboration session)
    const ydoc = ProsemirrorHelper.toYDoc("Initial text");
    document.state = Buffer.from(Y.encodeStateAsUpdate(ydoc));
    await document.save();

    document = await withAPIContext(user, (ctx) =>
      documentUpdater(ctx, {
        text: "Changed content",
        document,
      })
    );

    expect(notifyUpdateSpy).toHaveBeenCalledWith(document.id, user.id);
    notifyUpdateSpy.mockRestore();
  });

  it("should not notify collaboration server when only title changes", async () => {
    const notifyUpdateSpy = vi
      .spyOn(APIUpdateExtension, "notifyUpdate")
      .mockResolvedValue(undefined);

    const user = await buildUser();
    let document = await buildDocument({
      teamId: user.teamId,
    });

    document = await withAPIContext(user, (ctx) =>
      documentUpdater(ctx, {
        title: "New Title",
        document,
      })
    );

    expect(notifyUpdateSpy).not.toHaveBeenCalled();
    notifyUpdateSpy.mockRestore();
  });

  it("should keep the loaded collection when the title changes", async () => {
    const user = await buildUser();
    const collection = await buildCollection({
      userId: user.id,
      teamId: user.teamId,
    });
    const { id } = await buildDocument({
      userId: user.id,
      teamId: user.teamId,
      collectionId: collection.id,
    });
    const document = await Document.findByPk(id, {
      userId: user.id,
      rejectOnEmpty: true,
    });
    const loadedCollection = document.collection;

    const result = await withAPIContext(user, (ctx) =>
      documentUpdater(ctx, {
        title: "New Title",
        document,
      })
    );

    expect(result).toBe(document);
    expect(result.collection).toBe(loadedCollection);
    expect(result.collection?.documentStructure?.[0].title).toEqual(
      "New Title"
    );
  });

  describe("replace", () => {
    it("should update document content when changing text", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Changed",
          document,
        })
      );

      expect(document.text).toEqual("Changed");
      expect(document.content).toEqual({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                text: "Changed",
              },
            ],
          },
        ],
      });
    });
  });

  describe("append", () => {
    it("should append document content when requested", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "Initial",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Appended",
          document,
          editMode: TextEditMode.Append,
        })
      );

      expect(document.text).toEqual("InitialAppended");
      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "InitialAppended" }],
          },
        ],
      });
    });

    it("should preserve rich content when appending", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "**Bold**",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Appended",
          document,
          editMode: TextEditMode.Append,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                marks: [{ type: "strong" }],
                text: "Bold",
              },
              {
                type: "text",
                text: "Appended",
              },
            ],
          },
        ],
      });
    });

    it("should preserve rich content from JSON when appending", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const id = randomUUID();
      document.content = {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                marks: [{ type: "comment", attrs: { id, userId: id } }],
                text: "Italic",
              },
            ],
          },
        ],
      };
      await document.save();

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Appended",
          document,
          editMode: TextEditMode.Append,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                marks: [{ type: "comment", attrs: { id, userId: id } }],
                text: "Italic",
              },
              {
                type: "text",
                text: "Appended",
              },
            ],
          },
        ],
      });
    });

    it("should create new paragraph when appending with newline", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "Initial",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "\n\nAppended",
          document,
          editMode: TextEditMode.Append,
        })
      );

      expect(document.text).toEqual("Initial\n\nAppended");
      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "Initial" }],
          },
          {
            type: "paragraph",
            content: [{ type: "text", text: "Appended" }],
          },
        ],
      });
    });
  });

  describe("prepend", () => {
    it("should prepend document content when requested", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "Existing",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Prepended",
          document,
          editMode: TextEditMode.Prepend,
        })
      );

      expect(document.text).toEqual("PrependedExisting");
      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "PrependedExisting" }],
          },
        ],
      });
    });

    it("should preserve rich content when prepending", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "**Bold**",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Prepended",
          document,
          editMode: TextEditMode.Prepend,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                text: "Prepended",
              },
              {
                type: "text",
                marks: [{ type: "strong" }],
                text: "Bold",
              },
            ],
          },
        ],
      });
    });

    it("should create new paragraph when prepending with newline", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "Existing",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Prepended\n\n",
          document,
          editMode: TextEditMode.Prepend,
        })
      );

      expect(document.text).toEqual("Prepended\n\nExisting");
      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "Prepended" }],
          },
          {
            type: "paragraph",
            content: [{ type: "text", text: "Existing" }],
          },
        ],
      });
    });
  });

  describe("patch", () => {
    const listItem = (text: string, marks?: ProsemirrorMark[]) => ({
      type: "list_item",
      content: [
        {
          type: "paragraph",
          content: [{ type: "text", text, ...(marks && { marks }) }],
        },
      ],
    });

    /** The serialized markdown line of a document that contains the needle. */
    const lineContaining = async (doc: Document, needle: string) =>
      (await DocumentHelper.toMarkdown(doc, { includeTitle: false }))
        .split("\n")
        .find((line) => line.includes(needle))!;

    it("should patch specific text in document content", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "Hello world\n\nThis is a test",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Hello earth",
        TextEditMode.Patch,
        "Hello world"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(2);
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Hello earth" }],
      });
      expect(content[1]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "This is a test" }],
      });
    });

    it("should patch text retrieved via toMarkdown containing smart quotes and brackets", async () => {
      const user = await buildUser();
      const document = await buildDocument({
        teamId: user.teamId,
        content: {
          type: "doc",
          content: [
            {
              type: "paragraph",
              content: [{ type: "text", text: "the cat’s “meow” [loud]" }],
            },
          ],
        },
      });

      // findText is the exact text a client receives from retrieval.
      const findText = await DocumentHelper.toMarkdown(document, {
        includeTitle: false,
      });
      expect(findText).toBe("the cat’s “meow” [loud]");

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "quiet",
        TextEditMode.Patch,
        findText
      );

      expect(result.content!.content![0]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "quiet" }],
      });
    });

    it("should throw when findText is not found in document", async () => {
      const user = await buildUser();
      const document = await buildDocument({
        teamId: user.teamId,
        text: "Hello world",
      });

      await expect(
        withAPIContext(user, (ctx) =>
          documentUpdater(ctx, {
            text: "replacement",
            findText: "nonexistent text",
            document,
            editMode: TextEditMode.Patch,
          })
        )
      ).rejects.toThrow("The specified text was not found in the document");
    });

    it("should handle dollar signs in replacement text literally", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "The price is TBD",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "$100 & $200",
        TextEditMode.Patch,
        "TBD"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(1);
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "The price is $100 & $200" }],
      });
    });

    it("should delete matched text when replacement is empty string", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "Hello beautiful world",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "",
        TextEditMode.Patch,
        "beautiful "
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(1);
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Hello world" }],
      });
    });

    it("should handle multi-block replacement in a single paragraph", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });

      document.content = {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "Replace this text here" }],
          },
        ],
      };
      await document.save();

      // Replace with content that parses to multiple blocks
      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "First paragraph\n\nSecond paragraph",
        TextEditMode.Patch,
        "Replace this text here"
      );
      const content = result.content!.content!;

      // Should produce two paragraphs, not silently drop the second
      expect(content.length).toBeGreaterThanOrEqual(2);
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "First paragraph" }],
      });
      expect(content[1]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Second paragraph" }],
      });
    });

    it("should patch multi-block content", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "# Heading\n\nOld content\n\nKeep this",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "# New Heading\n\nNew content",
        TextEditMode.Patch,
        "# Heading\n\nOld content"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(3);
      expect(content[0]).toMatchObject({
        type: "heading",
        content: [{ type: "text", text: "New Heading" }],
      });
      expect(content[1]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "New content" }],
      });
      expect(content[2]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Keep this" }],
      });
    });

    it("should patch the middle item in a list", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "- First item\n- Second item\n- Third item",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "* Updated item",
          findText: "* Second item",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "bullet_list",
            content: [
              listItem("First item"),
              listItem("Updated item"),
              listItem("Third item"),
            ],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should keep a notice nested in its list item when patching another item", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "1. Alpha one.\n2. Bravo two:\n\n   :::info\n   A note inside item two.\n   :::\n3. Charlie three.",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "Amber",
          findText: "Alpha",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "ordered_list",
            content: [
              listItem("Amber one."),
              {
                type: "list_item",
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Bravo two:" }],
                  },
                  {
                    type: "container_notice",
                    content: [
                      {
                        type: "paragraph",
                        content: [
                          { type: "text", text: "A note inside item two." },
                        ],
                      },
                    ],
                  },
                ],
              },
              listItem("Charlie three."),
            ],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should append new blocks after a table when patching the last row", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "## Sizes\n\n| Quantity | Value |\n|---|---|\n| alpha | 1 |\n| beta | 2 |\n| gamma | 3 |\n",
      });

      const lastRow = await lineContaining(document, "gamma");

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: `${lastRow}\n\n## New\n\nbody text`,
          findText: lastRow,
          document,
          editMode: TextEditMode.Patch,
        })
      );

      const content = document.content as ProsemirrorData;
      expect(content.content?.map((n) => n.type)).toEqual([
        "heading",
        "table",
        "heading",
        "paragraph",
      ]);
      expect(content.content?.[1].content).toHaveLength(4);
      expect(document.text).toContain("gamma");
      expect(document.text).toContain("## New");
      expect(document.text).toContain("body text");
    });

    it("should not drop later rows when patching a middle table row with new blocks", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "| Quantity | Value |\n|---|---|\n| alpha | 1 |\n| beta | 2 |\n| gamma | 3 |\n",
      });

      const middleRow = await lineContaining(document, "beta");

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: `${middleRow}\n\n## New`,
          findText: middleRow,
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.text).toContain("beta");
      expect(document.text).toContain("## New");
      expect(document.text).toContain("gamma");
    });

    it("should keep list items when the replacement uses a different bullet marker", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "* alpha\n* beta\n* gamma",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "- beta EDITED",
          findText: "* beta",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "bullet_list",
            content: [
              listItem("alpha"),
              listItem("beta EDITED"),
              listItem("gamma"),
            ],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should keep later blockquote paragraphs when one is replaced with plain text", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "> alpha\n>\n> beta\n>\n> gamma\n\n## Notes",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "beta EDITED",
          findText: "> beta",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.text).toContain("alpha");
      expect(document.text).toContain("beta EDITED");
      expect(document.text).toContain("gamma");
      expect(document.text).toContain("## Notes");
    });

    it("should preserve comment marks on earlier rows when appending a row", async () => {
      const user = await buildUser();
      const commentId = randomUUID();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "| A | B |\n|---|---|\n| one | 1 |\n| two | 2 |",
      });

      // Attach a comment mark to the first body row cell text
      const content = document.content as ProsemirrorData;
      const table = content.content![0];
      const firstRow = table.content![1];
      const cellPara = firstRow.content![0].content![0];
      cellPara.content![0].marks = [
        { type: "comment", attrs: { id: commentId, userId: user.id } },
      ];
      document.content = content;
      document.changed("content", true);
      await document.save();

      const lastRow = await lineContaining(document, "two");

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: `${lastRow}\n${lastRow.replace("two", "three").replace("2", "3")}`,
          findText: lastRow,
          document,
          editMode: TextEditMode.Patch,
        })
      );

      const result = document.content as ProsemirrorData;
      const resultTable = result.content![0];
      expect(resultTable.type).toBe("table");
      expect(resultTable.content).toHaveLength(4);
      const resultCellPara = resultTable.content![1].content![0].content![0];
      expect(resultCellPara.content![0].marks).toMatchObject([
        { type: "comment", attrs: { id: commentId, userId: user.id } },
      ]);
      expect(document.text).toContain("three");
    });

    it("should preserve comment marks on both neighbours when editing a middle item and adding another", async () => {
      const user = await buildUser();
      const commentId = randomUUID();
      const commentMark = {
        type: "comment",
        attrs: { id: commentId, userId: user.id },
      };
      let document = await buildDocument({
        teamId: user.teamId,
        content: {
          type: "doc",
          content: [
            {
              type: "bullet_list",
              content: [
                listItem("alpha", [commentMark]),
                listItem("beta"),
                listItem("gamma", [commentMark]),
              ],
            },
          ],
        },
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "* beta EDITED\n* delta",
          findText: "* beta",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "bullet_list",
            content: [
              listItem("alpha", [commentMark]),
              listItem("beta EDITED"),
              listItem("delta"),
              listItem("gamma", [commentMark]),
            ],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should preserve comment marks on later items when removing an earlier item", async () => {
      const user = await buildUser();
      const commentId = randomUUID();
      const commentMark = {
        type: "comment",
        attrs: { id: commentId, userId: user.id },
      };
      let document = await buildDocument({
        teamId: user.teamId,
        content: {
          type: "doc",
          content: [
            {
              type: "bullet_list",
              content: [
                listItem("alpha"),
                listItem("beta"),
                listItem("gamma", [commentMark]),
              ],
            },
          ],
        },
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "",
          findText: "* alpha\n",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "bullet_list",
            content: [listItem("beta"), listItem("gamma", [commentMark])],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should replace a whole list with a paragraph", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "# Title\n\n* only item\n\nafter",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "plain text now",
          findText: "* only item",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      const content = document.content as ProsemirrorData;
      expect(content.content?.map((n) => n.type)).toEqual([
        "heading",
        "paragraph",
        "paragraph",
      ]);
      expect(document.text).toContain("plain text now");
      expect(document.text).not.toContain("only item");
      expect(document.text).toContain("after");
    });

    it("should keep all items when a bullet item is changed to an ordered item", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "* alpha\n* beta\n* gamma",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "1. beta EDITED",
          findText: "* beta",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      const content = document.content as ProsemirrorData;
      expect(content.content?.map((n) => n.type)).toEqual([
        "bullet_list",
        "ordered_list",
        "bullet_list",
        "paragraph",
      ]);
      expect(document.text).toContain("alpha");
      expect(document.text).toContain("beta EDITED");
      expect(document.text).toContain("gamma");
    });

    it("should keep one ordered list when the replacement uses a different delimiter", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "1. one\n2. two\n3. three",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "2) two EDITED",
          findText: "2. two",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "ordered_list",
            attrs: { order: 1 },
            content: [
              listItem("one"),
              listItem("two EDITED"),
              listItem("three"),
            ],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should not join adjacent ordered lists with different list styles", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "a. alpha",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "a. alpha\n1) numeric",
          findText: "a. alpha",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "ordered_list",
            attrs: { listStyle: "lower-alpha" },
            content: [listItem("alpha")],
          },
          {
            type: "ordered_list",
            attrs: { listStyle: "number" },
            content: [listItem("numeric")],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should keep nested items when a nested bullet marker changes", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "* alpha\n  * one\n  * two\n  * three\n* beta",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "  - two EDITED",
          findText: "  * two",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      expect(document.content).toMatchObject({
        type: "doc",
        content: [
          {
            type: "bullet_list",
            content: [
              {
                type: "list_item",
                content: [
                  { type: "paragraph" },
                  {
                    type: "bullet_list",
                    content: [
                      listItem("one"),
                      listItem("two EDITED"),
                      listItem("three"),
                    ],
                  },
                ],
              },
              listItem("beta"),
            ],
          },
          { type: "paragraph" },
        ],
      });
    });

    it("should append a new section after a blockquote that ends the document", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "> alpha\n>\n> beta",
      });

      document = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          text: "> beta\n\n## New\n\nbody",
          findText: "> beta",
          document,
          editMode: TextEditMode.Patch,
        })
      );

      const content = document.content as ProsemirrorData;
      expect(content.content?.map((n) => n.type)).toEqual([
        "blockquote",
        "heading",
        "paragraph",
      ]);
      expect(content.content?.[0].content).toHaveLength(2);
      expect(document.text).toContain("## New");
      expect(document.text).toContain("body");
    });

    it("should preserve comment marks on untouched blocks when patching", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      // Build content with a comment mark (not representable in markdown)
      // on the LAST paragraph, then patch the FIRST paragraph.
      document.content = {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "First paragraph" }],
          },
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                marks: [
                  {
                    type: "comment",
                    attrs: { id: commentId, userId: commentId },
                  },
                ],
                text: "Commented text",
              },
            ],
          },
          {
            type: "paragraph",
            content: [{ type: "text", text: "Third paragraph" }],
          },
        ],
      };
      await document.save();

      // Capture the ProseMirror JSON of untouched blocks BEFORE patching
      // (with schema defaults already applied via toProsemirror → toJSON)
      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const secondParaBefore = beforeDoc.content[1];
      const thirdParaBefore = beforeDoc.content[2];

      // Call DocumentHelper directly to inspect the ProseMirror result
      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Updated first",
        TextEditMode.Patch,
        "First paragraph"
      );
      const content = result.content!.content!;

      // Verify untouched blocks are byte-for-byte identical
      expect(content[1]).toEqual(secondParaBefore);
      expect(content[2]).toEqual(thirdParaBefore);

      // Verify the patched block was actually updated
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Updated first" }],
      });

      // Verify we still have exactly 3 blocks
      expect(content).toHaveLength(3);
    });

    it("should preserve comment marks when patching a later block", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      // Comment mark on the FIRST paragraph, patch the THIRD.
      document.content = {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                marks: [
                  {
                    type: "comment",
                    attrs: { id: commentId, userId: commentId },
                  },
                ],
                text: "Commented text",
              },
            ],
          },
          {
            type: "paragraph",
            content: [{ type: "text", text: "Middle paragraph" }],
          },
          {
            type: "paragraph",
            content: [{ type: "text", text: "Last paragraph" }],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const firstParaBefore = beforeDoc.content[0];
      const secondParaBefore = beforeDoc.content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Updated last",
        TextEditMode.Patch,
        "Last paragraph"
      );
      const content = result.content!.content!;

      // Both untouched blocks must be identical
      expect(content[0]).toEqual(firstParaBefore);
      expect(content[1]).toEqual(secondParaBefore);

      // Patched block updated
      expect(content[2]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Updated last" }],
      });
      expect(content).toHaveLength(3);
    });

    it("should preserve comment marks in complex document when patching middle content", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      // Complex document: heading, paragraph with comment, bullet list, paragraph to patch
      document.content = {
        type: "doc",
        content: [
          {
            type: "heading",
            attrs: { level: 2 },
            content: [{ type: "text", text: "Section Title" }],
          },
          {
            type: "paragraph",
            content: [
              {
                type: "text",
                marks: [
                  {
                    type: "comment",
                    attrs: { id: commentId, userId: commentId },
                  },
                ],
                text: "This has a comment",
              },
            ],
          },
          {
            type: "bullet_list",
            content: [
              {
                type: "list_item",
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Item one" }],
                  },
                ],
              },
              {
                type: "list_item",
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Item two" }],
                  },
                ],
              },
            ],
          },
          {
            type: "paragraph",
            content: [{ type: "text", text: "Final paragraph to edit" }],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Edited final paragraph",
        TextEditMode.Patch,
        "Final paragraph to edit"
      );
      const content = result.content!.content!;

      // All three untouched blocks must be byte-for-byte identical
      expect(content[0]).toEqual(beforeDoc.content[0]);
      expect(content[1]).toEqual(beforeDoc.content[1]);
      expect(content[2]).toEqual(beforeDoc.content[2]);

      // Patched block updated
      expect(content[3]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Edited final paragraph" }],
      });
      expect(content).toHaveLength(4);
    });

    it("should preserve comment mark when patching adjacent text in the same paragraph", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      // Paragraph: "Hello world " + "commented"(with comment mark) + " end"
      document.content = {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              { type: "text", text: "Hello world " },
              {
                type: "text",
                marks: [
                  {
                    type: "comment",
                    attrs: { id: commentId, userId: commentId },
                  },
                ],
                text: "commented",
              },
              { type: "text", text: " end" },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const commentNode = beforeDoc.content[0].content[1];
      const endNode = beforeDoc.content[0].content[2];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Goodbye world ",
        TextEditMode.Patch,
        "Hello world "
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(1);
      // Comment mark and trailing text must be preserved exactly
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [
          { type: "text", text: "Goodbye world " },
          commentNode,
          endNode,
        ],
      });
    });

    it("should preserve rich content in other checklist items when patching one item", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      // Checklist with 3 items; second item has a comment mark
      document.content = {
        type: "doc",
        content: [
          {
            type: "checkbox_list",
            content: [
              {
                type: "checkbox_item",
                attrs: { checked: false },
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "First task" }],
                  },
                ],
              },
              {
                type: "checkbox_item",
                attrs: { checked: true },
                content: [
                  {
                    type: "paragraph",
                    content: [
                      {
                        type: "text",
                        marks: [
                          {
                            type: "comment",
                            attrs: { id: commentId, userId: commentId },
                          },
                        ],
                        text: "Commented task",
                      },
                    ],
                  },
                ],
              },
              {
                type: "checkbox_item",
                attrs: { checked: false },
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Third task" }],
                  },
                ],
              },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const secondItem = beforeDoc.content[0].content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Updated task",
        TextEditMode.Patch,
        "First task"
      );
      const list = result.content!.content![0];

      // The second checklist item with its comment mark must be preserved
      expect(list.content![1]).toEqual(secondItem);

      // The first item should be updated
      expect(list.content![0].content![0].content![0].text).toEqual(
        "Updated task"
      );

      // All three items should still exist
      expect(list.content).toHaveLength(3);
    });

    it("should patch checklist item checked state while preserving rich content in siblings", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      document.content = {
        type: "doc",
        content: [
          {
            type: "checkbox_list",
            content: [
              {
                type: "checkbox_item",
                attrs: { checked: false },
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Buy groceries" }],
                  },
                ],
              },
              {
                type: "checkbox_item",
                attrs: { checked: false },
                content: [
                  {
                    type: "paragraph",
                    content: [
                      {
                        type: "text",
                        marks: [
                          {
                            type: "comment",
                            attrs: { id: commentId, userId: commentId },
                          },
                        ],
                        text: "Review PR",
                      },
                    ],
                  },
                ],
              },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const secondItem = beforeDoc.content[0].content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "- [x] Buy groceries",
        TextEditMode.Patch,
        "- [ ] Buy groceries"
      );
      const list = result.content!.content![0];

      // Checked state should be updated
      expect(list.content![0].attrs!.checked).toBe(true);

      // Text should remain the same
      expect(list.content![0].content![0].content![0].text).toEqual(
        "Buy groceries"
      );

      // Second item with comment mark must be preserved exactly
      expect(list.content![1]).toEqual(secondItem);
    });

    it("should uncheck a checkbox item while preserving rich content in siblings", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      // First item is checked, second has a comment mark
      document.content = {
        type: "doc",
        content: [
          {
            type: "checkbox_list",
            content: [
              {
                type: "checkbox_item",
                attrs: { checked: true },
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Buy groceries" }],
                  },
                ],
              },
              {
                type: "checkbox_item",
                attrs: { checked: false },
                content: [
                  {
                    type: "paragraph",
                    content: [
                      {
                        type: "text",
                        marks: [
                          {
                            type: "comment",
                            attrs: { id: commentId, userId: commentId },
                          },
                        ],
                        text: "Review PR",
                      },
                    ],
                  },
                ],
              },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const secondItem = beforeDoc.content[0].content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "- [ ] Buy groceries",
        TextEditMode.Patch,
        "- [x] Buy groceries"
      );
      const list = result.content!.content![0];

      // Checked state should be updated to false
      expect(list.content![0].attrs!.checked).toBe(false);

      // Text should remain the same
      expect(list.content![0].content![0].content![0].text).toEqual(
        "Buy groceries"
      );

      // Second item with comment mark must be preserved exactly
      expect(list.content![1]).toEqual(secondItem);
    });

    it("should preserve mention node when patching adjacent text in the same paragraph", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const mentionId = randomUUID();

      // Paragraph: "Hello " + @mention + " please review this"
      document.content = {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [
              { type: "text", text: "Hello " },
              {
                type: "mention",
                attrs: {
                  type: "user",
                  label: "Tom",
                  modelId: mentionId,
                  actorId: null,
                  id: mentionId,
                },
              },
              { type: "text", text: " please review this" },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const mentionNode = beforeDoc.content[0].content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "please approve this",
        TextEditMode.Patch,
        "please review this"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(1);
      expect(content[0].type).toEqual("paragraph");
      // The mention node must be preserved
      expect(content[0].content![1]).toEqual(mentionNode);
    });

    it("should preserve table colwidth when patching a different cell", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });

      // Table with colwidth set on cells (not representable in markdown)
      document.content = {
        type: "doc",
        content: [
          {
            type: "table",
            content: [
              {
                type: "tr",
                content: [
                  {
                    type: "th",
                    attrs: {
                      colspan: 1,
                      rowspan: 1,
                      colwidth: [150],
                      alignment: null,
                    },
                    content: [
                      {
                        type: "paragraph",
                        content: [{ type: "text", text: "Header A" }],
                      },
                    ],
                  },
                  {
                    type: "th",
                    attrs: {
                      colspan: 1,
                      rowspan: 1,
                      colwidth: [250],
                      alignment: null,
                    },
                    content: [
                      {
                        type: "paragraph",
                        content: [{ type: "text", text: "Header B" }],
                      },
                    ],
                  },
                ],
              },
              {
                type: "tr",
                content: [
                  {
                    type: "td",
                    attrs: {
                      colspan: 1,
                      rowspan: 1,
                      colwidth: [150],
                      alignment: null,
                    },
                    content: [
                      {
                        type: "paragraph",
                        content: [{ type: "text", text: "Cell to edit" }],
                      },
                    ],
                  },
                  {
                    type: "td",
                    attrs: {
                      colspan: 1,
                      rowspan: 1,
                      colwidth: [250],
                      alignment: null,
                    },
                    content: [
                      {
                        type: "paragraph",
                        content: [{ type: "text", text: "Keep this" }],
                      },
                    ],
                  },
                ],
              },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      // Second cell in second row should retain colwidth
      const unchangedCell = beforeDoc.content[0].content[1].content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Updated cell",
        TextEditMode.Patch,
        "Cell to edit"
      );
      const table = result.content!.content![0];
      const secondRow = table.content![1];

      // The unchanged cell must preserve its colwidth attr
      expect(secondRow.content![1]).toEqual(unchangedCell);
      expect(secondRow.content![1].attrs!.colwidth).toEqual([250]);
    });

    it("should preserve trailing whitespace in checklist items when patching", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });

      // Checklist where an item ends with a hard break (trailing spaces matter)
      document.content = {
        type: "doc",
        content: [
          {
            type: "checkbox_list",
            content: [
              {
                type: "checkbox_item",
                attrs: { checked: false },
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Edit me" }],
                  },
                ],
              },
              {
                type: "checkbox_item",
                attrs: { checked: false },
                content: [
                  {
                    type: "paragraph",
                    content: [
                      { type: "text", text: "Line one" },
                      { type: "br" },
                      { type: "text", text: "Line two" },
                    ],
                  },
                ],
              },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const secondItem = beforeDoc.content[0].content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Edited",
        TextEditMode.Patch,
        "Edit me"
      );
      const list = result.content!.content![0];

      // The second item with its hard break must be preserved exactly
      expect(list.content![1]).toEqual(secondItem);
    });

    it("should preserve rich content in blockquote when patching", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });
      const commentId = randomUUID();

      // Blockquote with two paragraphs; second has a comment mark
      document.content = {
        type: "doc",
        content: [
          {
            type: "blockquote",
            content: [
              {
                type: "paragraph",
                content: [{ type: "text", text: "Edit this line" }],
              },
              {
                type: "paragraph",
                content: [
                  {
                    type: "text",
                    marks: [
                      {
                        type: "comment",
                        attrs: { id: commentId, userId: commentId },
                      },
                    ],
                    text: "Keep this comment",
                  },
                ],
              },
            ],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const secondPara = beforeDoc.content[0].content[1];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Edited line",
        TextEditMode.Patch,
        "Edit this line"
      );
      const blockquote = result.content!.content![0];

      // Patched paragraph updated
      expect(blockquote.content![0].content![0].text).toEqual("Edited line");

      // Second paragraph with comment mark preserved exactly
      expect(blockquote.content![1]).toEqual(secondPara);
    });

    it("should preserve a blockquote containing a table when patching a later link", async () => {
      const user = await buildUser();
      const document = await buildDocument({ teamId: user.teamId });
      const fillerParagraphs: ProsemirrorData[] = Array.from(
        { length: 242 },
        (_, index) => ({
          type: "paragraph",
          content: [{ type: "text", text: `Quoted paragraph ${index + 1}` }],
        })
      );

      document.content = {
        type: "doc",
        content: [
          {
            type: "paragraph",
            content: [{ type: "text", text: "Before quote" }],
          },
          {
            type: "blockquote",
            content: [
              {
                type: "heading",
                attrs: { level: 1 },
                content: [{ type: "text", text: "Quoted source" }],
              },
              {
                type: "table",
                content: [
                  {
                    type: "tr",
                    content: [
                      {
                        type: "th",
                        content: [
                          {
                            type: "paragraph",
                            content: [{ type: "text", text: "Key" }],
                          },
                        ],
                      },
                      {
                        type: "th",
                        content: [
                          {
                            type: "paragraph",
                            content: [{ type: "text", text: "Value" }],
                          },
                        ],
                      },
                    ],
                  },
                  {
                    type: "tr",
                    content: [
                      {
                        type: "td",
                        content: [
                          {
                            type: "paragraph",
                            content: [{ type: "text", text: "Alpha" }],
                          },
                        ],
                      },
                      {
                        type: "td",
                        content: [
                          {
                            type: "paragraph",
                            content: [{ type: "text", text: "Keep this row" }],
                          },
                        ],
                      },
                    ],
                  },
                ],
              },
              ...fillerParagraphs,
              {
                type: "paragraph",
                content: [
                  { type: "text", text: "See " },
                  {
                    type: "text",
                    marks: [
                      {
                        type: "link",
                        attrs: { href: "https://example.com/reference" },
                      },
                    ],
                    text: "Reference",
                  },
                  { type: "text", text: "." },
                ],
              },
              {
                type: "paragraph",
                content: [{ type: "text", text: "Tail sentinel." }],
              },
            ],
          },
          {
            type: "paragraph",
            content: [{ type: "text", text: "After quote" }],
          },
        ],
      };
      await document.save();

      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const expected = structuredClone(beforeDoc);
      expected.content[1].content[244].content[1].marks[0].attrs.href =
        "https://example.com/reference#h-target";

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "> See [Reference](https://example.com/reference#h-target).",
        TextEditMode.Patch,
        "> See [Reference](https://example.com/reference)."
      );

      expect(result.content).toEqual(expected);
    });

    it("should always patch the first occurrence when findText appears multiple times", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "TODO item\n\nSome details\n\nTODO item",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "DONE item",
        TextEditMode.Patch,
        "TODO item"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(3);
      // First occurrence replaced
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "DONE item" }],
      });
      // Middle paragraph unchanged
      expect(content[1]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "Some details" }],
      });
      // Second occurrence untouched
      expect(content[2]).toMatchObject({
        type: "paragraph",
        content: [{ type: "text", text: "TODO item" }],
      });
    });

    it("should patch text containing inline formatting", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
        text: "This is **bold** text",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "This is **strong** text",
        TextEditMode.Patch,
        "This is **bold** text"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(1);
      expect(content[0]).toMatchObject({
        type: "paragraph",
        content: [
          { type: "text", text: "This is " },
          {
            type: "text",
            marks: [{ type: "strong" }],
            text: "strong",
          },
          { type: "text", text: " text" },
        ],
      });
    });

    it("should insert replacement text verbatim inside a code block", async () => {
      const user = await buildUser();
      const document = await buildDocument({
        teamId: user.teamId,
        text: "```bash\nfirst line\necho start\n  echo indented\nlast line\n```",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "echo `date`\n  echo indented",
        TextEditMode.Patch,
        "echo start\n  echo indented"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(2);
      expect(content[1].type).toBe("paragraph");
      expect(content[0]).toMatchObject({
        type: "code_block",
        content: [
          {
            type: "text",
            text: "first line\necho `date`\n  echo indented\nlast line",
          },
        ],
      });
      expect(content[0].content![0].marks).toBeUndefined();
      expect(() => schema.nodeFromJSON(result.content!).check()).not.toThrow();
    });

    it("should not apply marks when patching a single line in a code block", async () => {
      const user = await buildUser();
      const document = await buildDocument({
        teamId: user.teamId,
        text: "```python\nclass A:\n    def init(self):\n        pass\n```",
      });

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "def __init__(self):",
        TextEditMode.Patch,
        "def init(self):"
      );
      const content = result.content!.content!;

      expect(content).toHaveLength(2);
      expect(content[1].type).toBe("paragraph");
      expect(content[0]).toMatchObject({
        type: "code_block",
        content: [
          {
            type: "text",
            text: "class A:\n    def __init__(self):\n        pass",
          },
        ],
      });
      expect(content[0].content![0].marks).toBeUndefined();
    });

    it("should preserve ordered list container attrs when patching an item", async () => {
      const user = await buildUser();
      let document = await buildDocument({
        teamId: user.teamId,
      });

      // Ordered list starting at 3 with lower-alpha style
      document.content = {
        type: "doc",
        content: [
          {
            type: "ordered_list",
            attrs: { order: 3, listStyle: "lower-alpha" },
            content: [
              {
                type: "list_item",
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "First item" }],
                  },
                ],
              },
              {
                type: "list_item",
                content: [
                  {
                    type: "paragraph",
                    content: [{ type: "text", text: "Second item" }],
                  },
                ],
              },
            ],
          },
        ],
      };
      await document.save();

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "Updated item",
        TextEditMode.Patch,
        "First item"
      );
      const list = result.content!.content![0];

      // Container attrs must be preserved
      expect(list.attrs!.order).toEqual(3);
      expect(list.attrs!.listStyle).toEqual("lower-alpha");

      // Patched item updated
      expect(list.content![0].content![0].content![0].text).toEqual(
        "Updated item"
      );

      // Unchanged item preserved
      expect(list.content![1].content![0].content![0].text).toEqual(
        "Second item"
      );
    });

    it("should apply a link when wrapping existing table cell text", async () => {
      const user = await buildUser();
      const document = await buildDocument({ teamId: user.teamId });

      document.content = parser
        .parse("| Name | Notes |\n|------|-------|\n| Alpha | see |\n")
        .toJSON();
      await document.save();

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "[see](https://example.com/docs)",
        TextEditMode.Patch,
        "see"
      );

      // The cell text is unchanged but should now carry a link mark — it must
      // not be silently dropped during the merge.
      const cellText =
        result.content!.content![0].content![1].content![1].content![0]
          .content![0];
      expect(cellText.text).toEqual("see");
      expect(cellText.marks).toEqual([
        expect.objectContaining({
          type: "link",
          attrs: expect.objectContaining({ href: "https://example.com/docs" }),
        }),
      ]);
    });

    it("should preserve other table cells when adding a link to one cell", async () => {
      const user = await buildUser();
      const document = await buildDocument({ teamId: user.teamId });

      document.content = parser
        .parse(
          "| Name | Notes |\n|------|-------|\n| Alpha | see |\n| Beta | other |\n"
        )
        .toJSON();
      await document.save();

      // Capture the untouched (Beta) row BEFORE patching so we can assert its
      // structure and attrs are preserved exactly, not just its text.
      const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
      const untouchedRow = beforeDoc.content[0].content[2];

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "see [docs](https://example.com/d)",
        TextEditMode.Patch,
        "see"
      );

      // The patched cell gained the link
      expect(result.text).toContain("[docs](https://example.com/d)");
      // The untouched row node must remain identical
      expect(result.content!.content![0].content![2]).toEqual(untouchedRow);
    });

    it("should apply a mark when wrapping existing list item text", async () => {
      const user = await buildUser();
      const document = await buildDocument({ teamId: user.teamId });

      document.content = parser.parse("- clickme\n- other\n").toJSON();
      await document.save();

      const result = DocumentHelper.applyMarkdownToDocument(
        document,
        "[clickme](https://example.com)",
        TextEditMode.Patch,
        "clickme"
      );

      expect(result.text).toContain("[clickme](https://example.com)");
      expect(result.text).toContain("other");
    });
  });

  describe("block ids", () => {
    const paragraph = (text: string) => ({
      type: "paragraph",
      content: [{ type: "text", text }],
    });
    const listId = randomUUID();
    const toggleId = randomUUID();
    const content: ProsemirrorData = {
      type: "doc",
      content: [
        {
          type: "checkbox_list",
          attrs: { id: listId },
          content: ["one", "two"].map((text) => ({
            type: "checkbox_item",
            attrs: { checked: false },
            content: [paragraph(text)],
          })),
        },
        {
          type: "container_toggle",
          attrs: { id: toggleId },
          content: [paragraph("Title"), paragraph("Body")],
        },
      ],
    };

    const blockIds = (document: Document) =>
      (document.content?.content ?? [])
        .filter((node) => node.type !== "paragraph")
        .map((node) => node.attrs?.id);

    it("assigns ids and a trailing paragraph to markdown written through the API", async () => {
      const user = await buildUser();
      const document = await buildDocument({ teamId: user.teamId });

      const result = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          document,
          text: "- [ ] one\n\n+++ Title\nBody\n+++",
        })
      );

      const [checklist, toggle] = blockIds(result);
      expect(checklist).toEqual(expect.any(String));
      expect(toggle).toEqual(expect.any(String));
      expect(result.content?.content?.at(-1)?.type).toBe("paragraph");
      expect(result.text.trim().endsWith("+++")).toBe(true);
    });

    it.each([
      ["checkbox list", "two", "three"],
      ["toggle block", "Body", "Changed body"],
    ])("keeps the id of a patched %s", async (_name, findText, replacement) => {
      const user = await buildUser();
      const document = await buildDocument({
        teamId: user.teamId,
        content,
      });

      const result = await withAPIContext(user, (ctx) =>
        documentUpdater(ctx, {
          document,
          text: replacement,
          editMode: TextEditMode.Patch,
          findText,
        })
      );

      expect(result.text).toContain(replacement);
      expect(blockIds(result)).toEqual([listId, toggleId]);
    });
  });
});