Ctrl K
ComunidadGuías…

server/models/Document.test.ts

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

Archivo original ↗Preguntar a KiBot
import { createHash } from "node:crypto";
import { EmptyResultError, Op, QueryTypes } from "sequelize";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import slugify from "@server/utils/slugify";
import { parser } from "@server/editor";
import Document from "@server/models/Document";
import {
  buildDocument,
  buildDraftDocument,
  buildCollection,
  buildComment,
  buildResolvedComment,
  buildGroup,
  buildTeam,
  buildUser,
  buildGuestUser,
} from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import { sequelize } from "@server/storage/database";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import UserMembership from "./UserMembership";

beforeEach(() => {
  vi.resetAllMocks();
});

describe("#restoreTo", () => {
  it.each(["archived", "deleted"])(
    "should clear a reason saved after an %s document was loaded",
    async (status) => {
      const user = await buildUser();
      const collection = await buildCollection({
        userId: user.id,
        teamId: user.teamId,
      });
      const document = await buildDocument({
        userId: user.id,
        teamId: user.teamId,
        collectionId: collection.id,
        archivedAt: status === "archived" ? new Date() : null,
        deletedAt: status === "deleted" ? new Date() : null,
      });

      // Simulate another request saving a reason after the restore loaded its model.
      await Document.update(
        { deprecatedReason: "Outdated" },
        { where: { id: document.id }, paranoid: false }
      );
      await withAPIContext(user, (ctx) =>
        document.restoreTo(ctx, { collectionId: collection.id })
      );
      await document.reload();
      expect(document.isActive).toBe(true);
      expect(document.deprecatedReason).toBeNull();
    }
  );
});

describe("#getSummary", () => {
  it("should strip markdown", async () => {
    const document = await buildDocument({
      version: 1,
      text: `*paragraph*

paragraph 2`,
    });
    expect(document.getSummary()).toBe("paragraph");
  });

  it("should strip title when no version", async () => {
    const document = await buildDocument({
      version: 0,
      text: `# Heading

*paragraph*`,
    });
    expect(document.getSummary()).toBe("paragraph");
  });
});

describe("#destroyWithCtx", () => {
  it("should soft delete and set last modified", async () => {
    const document = await buildDocument();
    const user = await buildUser();
    await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx));

    const newDocument = await Document.findByPk(document.id, {
      paranoid: false,
    });
    expect(newDocument?.lastModifiedById).toBe(user.id);
    expect(newDocument?.deletedAt).toBeTruthy();
  });

  it("should soft delete archived", async () => {
    const document = await buildDocument({
      archivedAt: new Date(),
    });
    const user = await buildUser();
    await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx));
    const newDocument = await Document.findByPk(document.id, {
      paranoid: false,
    });
    expect(newDocument?.lastModifiedById).toBe(user.id);
    expect(newDocument?.deletedAt).toBeTruthy();
  });

  it("should soft delete archived document in an archived collection", async () => {
    const user = await buildUser();
    const collection = await buildCollection({
      archivedAt: new Date(),
      createdById: user.id,
      teamId: user.teamId,
    });
    const document = await buildDocument({
      archivedAt: new Date(),
      collectionId: collection.id,
      userId: user.id,
      teamId: user.teamId,
    });
    await collection.addDocumentToStructure(document, 0);

    await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx));
    const [newDocument, newCollection] = await Promise.all([
      document.reload({ paranoid: false }),
      collection.reload(),
    ]);

    expect(newDocument?.lastModifiedById).toEqual(user.id);
    expect(newDocument?.deletedAt).toBeTruthy();
    expect(newCollection?.documentStructure).toEqual([]);
  });

  it("should delete draft without collection", async () => {
    const user = await buildUser();
    const document = await buildDraftDocument();
    await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx));
    const deletedDocument = await Document.findByPk(document.id, {
      paranoid: false,
    });
    expect(deletedDocument?.lastModifiedById).toBe(user.id);
    expect(deletedDocument?.deletedAt).toBeTruthy();
  });
});

describe("#save", () => {
  it("should have empty previousTitles by default", async () => {
    const document = await buildDocument();
    expect(document.previousTitles).toBe(null);
  });

  it("should include previousTitles on save", async () => {
    const document = await buildDocument();
    document.title = "test";
    await document.save();
    expect(document.previousTitles.length).toBe(1);
  });

  it("should not duplicate previousTitles", async () => {
    const document = await buildDocument();
    document.title = "test";
    await document.save();
    document.title = "example";
    await document.save();
    document.title = "test";
    await document.save();
    expect(document.previousTitles.length).toBe(3);
  });

  it("should index text whose search vector exceeds the tsvector limit", async () => {
    const document = await buildDocument({ title: "Original" });
    const tokens: string[] = [];
    for (let i = 0; i < 40_000; i++) {
      tokens.push(createHash("md5").update(String(i)).digest("hex"));
    }
    document.title = "Unique tokens";
    document.text = tokens.join(" ");
    await document.save();

    const [rows] = await sequelize.query<{ searchVector: string }>(
      `SELECT "searchVector" FROM documents WHERE id = :id`,
      { replacements: { id: document.id }, type: QueryTypes.SELECT }
    );
    expect(rows.searchVector).toContain("'uniqu':1A");
    expect(rows.searchVector).toMatch(/'origin':\d+C/);
    expect(rows.searchVector).toContain(tokens[0]);
  });
});

describe("#findAllChildDocumentIds", () => {
  it("should return empty array if no children", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const collection = await buildCollection({
      userId: user.id,
      teamId: team.id,
    });
    const document = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      title: "test",
    });
    const results = await document.findAllChildDocumentIds();
    expect(results.length).toBe(0);
  });

  it("should return nested child document ids", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const collection = await buildCollection({
      userId: user.id,
      teamId: team.id,
    });
    const document = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      title: "test",
    });
    const document2 = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      parentDocumentId: document.id,
      title: "test",
    });
    const document3 = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      parentDocumentId: document2.id,
      title: "test",
    });
    const results = await document.findAllChildDocumentIds();
    expect(results.length).toBe(2);
    expect(results[0]).toBe(document2.id);
    expect(results[1]).toBe(document3.id);
  });

  it("should apply where filter and prune unmatched branches", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const collection = await buildCollection({
      userId: user.id,
      teamId: team.id,
    });
    const document = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      title: "test",
    });
    const publishedChild = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      parentDocumentId: document.id,
      title: "published",
    });
    // A draft (unpublished) child, with its own nested child.
    const draftChild = await buildDraftDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      parentDocumentId: document.id,
      title: "draft",
    });
    await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      parentDocumentId: draftChild.id,
      title: "published under draft",
    });

    const results = await document.findAllChildDocumentIds({
      publishedAt: {
        [Op.ne]: null,
      },
    });
    // Only the published child is returned; the draft branch is pruned so its
    // descendants are not traversed.
    expect(results).toEqual([publishedChild.id]);
  });

  it("should include soft-deleted children when paranoid is false", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const collection = await buildCollection({
      userId: user.id,
      teamId: team.id,
    });
    const document = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      title: "test",
    });
    const child = await buildDocument({
      userId: user.id,
      teamId: team.id,
      collectionId: collection.id,
      parentDocumentId: document.id,
      title: "child",
    });
    await child.destroy();

    expect(await document.findAllChildDocumentIds()).toEqual([]);
    expect(
      await document.findAllChildDocumentIds(undefined, { paranoid: false })
    ).toEqual([child.id]);
  });
});

describe("#toNavigationNode", () => {
  it("should build the nested tree of published children", async () => {
    const collection = await buildCollection();
    const props = {
      teamId: collection.teamId,
      collectionId: collection.id,
    };
    const root = await buildDocument({ ...props, title: "Root" });
    const childA = await buildDocument({
      ...props,
      parentDocumentId: root.id,
      title: "Child A",
    });
    const childB = await buildDocument({
      ...props,
      parentDocumentId: root.id,
      title: "Child B",
    });
    const grandchild = await buildDocument({
      ...props,
      parentDocumentId: childA.id,
      title: "Grandchild",
    });
    await buildDraftDocument({ ...props, parentDocumentId: root.id });

    const node = await root.toNavigationNode();

    expect(node.id).toBe(root.id);
    expect(node.url).toBe(root.url);
    expect(node.children.map((child) => child.id).sort()).toEqual(
      [childA.id, childB.id].sort()
    );

    const nodeA = node.children.find((child) => child.id === childA.id);
    expect(nodeA?.title).toBe("Child A");
    expect(nodeA?.url).toBe(childA.url);
    expect(nodeA?.children).toEqual([
      {
        id: grandchild.id,
        title: "Grandchild",
        url: grandchild.url,
        icon: undefined,
        color: undefined,
        children: [],
      },
    ]);
  });

  it("should only include archived children when requested", async () => {
    const collection = await buildCollection();
    const root = await buildDocument({
      teamId: collection.teamId,
      collectionId: collection.id,
    });
    const archived = await buildDocument({
      teamId: collection.teamId,
      collectionId: collection.id,
      parentDocumentId: root.id,
      archivedAt: new Date(),
    });

    expect((await root.toNavigationNode()).children).toEqual([]);
    expect(
      (await root.toNavigationNode({ includeArchived: true })).children.map(
        (child) => child.id
      )
    ).toEqual([archived.id]);
  });
});

describe("#findAllParentDocumentIds", () => {
  it("should return empty array if there is no parent", async () => {
    const document = await buildDocument();

    expect(await document.findAllParentDocumentIds()).toEqual([]);
  });

  it("should return nested parent document ids in one query", async () => {
    const parent = await buildDocument();
    const child = await buildDocument({
      parentDocumentId: parent.id,
      collectionId: parent.collectionId,
      teamId: parent.teamId,
      userId: parent.createdById,
    });
    const grandchild = await buildDocument({
      parentDocumentId: child.id,
      collectionId: parent.collectionId,
      teamId: parent.teamId,
      userId: parent.createdById,
    });

    const query = sequelize.query.bind(sequelize);
    const spy = vi.spyOn(sequelize, "query").mockImplementation(query);
    try {
      const results = await grandchild.findAllParentDocumentIds();

      expect(results).toEqual([child.id, parent.id]);
      expect(spy).toHaveBeenCalledTimes(1);
    } finally {
      spy.mockRestore();
    }
  });

  it("should stop at soft-deleted parents unless paranoid is false", async () => {
    const parent = await buildDocument();
    const child = await buildDocument({
      parentDocumentId: parent.id,
      collectionId: parent.collectionId,
      teamId: parent.teamId,
      userId: parent.createdById,
    });
    const grandchild = await buildDocument({
      parentDocumentId: child.id,
      collectionId: parent.collectionId,
      teamId: parent.teamId,
      userId: parent.createdById,
    });
    await child.destroy();

    expect(await grandchild.findAllParentDocumentIds()).toEqual([]);
    expect(
      await grandchild.findAllParentDocumentIds({ paranoid: false })
    ).toEqual([child.id, parent.id]);
  });
});

describe("#membershipUserIds", () => {
  it("should return unique users with direct and group memberships", async () => {
    const team = await buildTeam();
    const owner = await buildUser({ teamId: team.id });
    const [direct, grouped, both] = await Promise.all([
      buildUser({ teamId: team.id }),
      buildUser({ teamId: team.id }),
      buildUser({ teamId: team.id }),
    ]);
    const document = await buildDocument({
      teamId: team.id,
      userId: owner.id,
    });
    const group = await buildGroup({ teamId: team.id });
    await group.$add("user", grouped, { through: { createdById: owner.id } });
    await group.$add("user", both, { through: { createdById: owner.id } });
    await GroupMembership.create({
      createdById: owner.id,
      groupId: group.id,
      documentId: document.id,
      permission: DocumentPermission.Read,
    });
    await UserMembership.create({
      createdById: owner.id,
      documentId: document.id,
      userId: direct.id,
      permission: DocumentPermission.ReadWrite,
    });
    await UserMembership.create({
      createdById: owner.id,
      documentId: document.id,
      userId: both.id,
      permission: DocumentPermission.Read,
    });

    const userIds = await Document.membershipUserIds(document.id);
    expect(userIds.sort()).toEqual([direct.id, grouped.id, both.id].sort());

    const writerIds = await Document.membershipUserIds(
      document.id,
      DocumentPermission.ReadWrite
    );
    expect(writerIds).toEqual([direct.id]);
  });
});

describe("#membershipDocumentIds", () => {
  it("should return empty array when the user has no document memberships", async () => {
    const user = await buildUser();
    const ids = await Document.membershipDocumentIds(user.id);
    expect(ids).toEqual([]);
  });

  it("should return documents shared directly with the user", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });
    await UserMembership.create({
      createdById: otherUser.id,
      documentId: document.id,
      userId: user.id,
      permission: DocumentPermission.Read,
    });

    const ids = await Document.membershipDocumentIds(user.id);
    expect(ids).toEqual([document.id]);
  });

  it("should return documents shared with the user through a group", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });
    const group = await buildGroup({ teamId: team.id });
    await group.$add("user", user, { through: { createdById: otherUser.id } });
    await GroupMembership.create({
      createdById: otherUser.id,
      groupId: group.id,
      documentId: document.id,
      permission: DocumentPermission.Read,
    });

    const ids = await Document.membershipDocumentIds(user.id);
    expect(ids).toEqual([document.id]);
  });

  it("should deduplicate documents shared both directly and through a group", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });
    await UserMembership.create({
      createdById: otherUser.id,
      documentId: document.id,
      userId: user.id,
      permission: DocumentPermission.Read,
    });
    const group = await buildGroup({ teamId: team.id });
    await group.$add("user", user, { through: { createdById: otherUser.id } });
    await GroupMembership.create({
      createdById: otherUser.id,
      groupId: group.id,
      documentId: document.id,
      permission: DocumentPermission.Read,
    });

    const ids = await Document.membershipDocumentIds(user.id);
    expect(ids).toEqual([document.id]);
  });

  it("should not return memberships of other users or collections", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const collection = await buildCollection({
      teamId: team.id,
      userId: otherUser.id,
    });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
      collectionId: collection.id,
    });
    // document membership for another user
    await UserMembership.create({
      createdById: otherUser.id,
      documentId: document.id,
      userId: otherUser.id,
      permission: DocumentPermission.Read,
    });
    // collection-level membership for this user
    await UserMembership.create({
      createdById: otherUser.id,
      collectionId: collection.id,
      userId: user.id,
      permission: CollectionPermission.Read,
    });

    const ids = await Document.membershipDocumentIds(user.id);
    expect(ids).toEqual([]);
  });

  it("should not return documents from deleted group memberships", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });
    const group = await buildGroup({ teamId: team.id });
    await group.$add("user", user, { through: { createdById: otherUser.id } });
    const membership = await GroupMembership.create({
      createdById: otherUser.id,
      groupId: group.id,
      documentId: document.id,
      permission: DocumentPermission.Read,
    });
    await membership.destroy();

    const ids = await Document.membershipDocumentIds(user.id);
    expect(ids).toEqual([]);
  });

  it("should invalidate the cache when a direct membership is added or removed", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });

    expect(await Document.membershipDocumentIds(user.id)).toEqual([]);

    const membership = await UserMembership.create({
      createdById: otherUser.id,
      documentId: document.id,
      userId: user.id,
      permission: DocumentPermission.Read,
    });
    expect(await Document.membershipDocumentIds(user.id)).toEqual([
      document.id,
    ]);

    await membership.destroy();
    expect(await Document.membershipDocumentIds(user.id)).toEqual([]);
  });

  it("should invalidate the cache when a group membership is added or removed", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });
    const group = await buildGroup({ teamId: team.id });
    await group.$add("user", user, { through: { createdById: otherUser.id } });

    expect(await Document.membershipDocumentIds(user.id)).toEqual([]);

    const membership = await GroupMembership.create({
      createdById: otherUser.id,
      groupId: group.id,
      documentId: document.id,
      permission: DocumentPermission.Read,
    });
    expect(await Document.membershipDocumentIds(user.id)).toEqual([
      document.id,
    ]);

    await membership.destroy();
    expect(await Document.membershipDocumentIds(user.id)).toEqual([]);
  });

  it("should invalidate the cache when the user joins or leaves a group", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });
    const group = await buildGroup({ teamId: team.id });
    await GroupMembership.create({
      createdById: otherUser.id,
      groupId: group.id,
      documentId: document.id,
      permission: DocumentPermission.Read,
    });

    expect(await Document.membershipDocumentIds(user.id)).toEqual([]);

    const groupUser = await GroupUser.create({
      groupId: group.id,
      userId: user.id,
      createdById: otherUser.id,
    });
    expect(await Document.membershipDocumentIds(user.id)).toEqual([
      document.id,
    ]);

    await groupUser.destroy();
    expect(await Document.membershipDocumentIds(user.id)).toEqual([]);
  });

  it("should read through to the database when skipCache is set", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const otherUser = await buildUser({ teamId: team.id });
    const document = await buildDocument({
      teamId: team.id,
      userId: otherUser.id,
    });

    expect(await Document.membershipDocumentIds(user.id)).toEqual([]);

    await UserMembership.create({
      createdById: otherUser.id,
      documentId: document.id,
      userId: user.id,
      permission: DocumentPermission.Read,
    });

    expect(
      await Document.membershipDocumentIds(user.id, { skipCache: true })
    ).toEqual([document.id]);
  });
});

describe("#findByPk", () => {
  it("should return document when urlId is correct", async () => {
    const document = await buildDocument();
    const id = `${slugify(document.title)}-${document.urlId}`;
    const response = await Document.findByPk(id);
    expect(response?.id).toBe(document.id);
  });

  it("should return document when urlId is given without the slug prefix", async () => {
    const document = await buildDocument();
    const id = document.urlId;
    const response = await Document.findByPk(id);
    expect(response?.id).toBe(document.id);
  });

  it("should test with rejectOnEmpty flag", async () => {
    const user = await buildUser();
    const document = await buildDocument({
      teamId: user.teamId,
      createdById: user.id,
    });
    await expect(
      Document.findByPk(document.id, {
        userId: user.id,
        rejectOnEmpty: true,
      })
    ).resolves.not.toBeNull();

    await expect(
      Document.findByPk(document.urlId, {
        userId: user.id,
        rejectOnEmpty: true,
      })
    ).resolves.not.toBeNull();

    await expect(
      Document.findByPk("0e8280ea-7b4c-40e5-98ba-ec8a2f00f5e8", {
        userId: user.id,
        rejectOnEmpty: true,
      })
    ).rejects.toThrow(EmptyResultError);

    await expect(
      Document.findByPk("not a valid id", {
        userId: user.id,
        rejectOnEmpty: true,
      })
    ).rejects.toThrow(EmptyResultError);

    await expect(
      Document.findByPk(123, {
        userId: user.id,
        rejectOnEmpty: true,
      })
    ).rejects.toThrow(EmptyResultError);
  });

  it("should omit content columns when includeContent is false", async () => {
    const document = await buildDocument({ text: "# Heading" });

    const response = await Document.findByPk(document.id, {
      includeContent: false,
    });
    expect(response?.id).toBe(document.id);
    expect(response?.title).toBe(document.title);
    expect(response?.dataValues.content).toBeUndefined();
    expect(response?.dataValues.text).toBeUndefined();
    expect(response?.dataValues.state).toBeUndefined();

    const withContent = await Document.findByPk(document.id);
    expect(withContent?.dataValues.content).toBeDefined();
    expect(withContent?.dataValues.text).toBeDefined();
  });

  it("should not allow a passed where to override the id", async () => {
    const document = await buildDocument();
    const other = await buildDocument();

    const response = await Document.findByPk(document.id, {
      where: { id: other.id },
    });
    expect(response?.id).toBe(document.id);

    const byUrlId = await Document.findByPk(document.urlId, {
      where: { urlId: other.urlId },
    });
    expect(byUrlId?.id).toBe(document.id);
  });

  it("should throw the passed error when rejectOnEmpty is an error", async () => {
    const error = new Error("does not exist");
    await expect(
      Document.findByPk("0e8280ea-7b4c-40e5-98ba-ec8a2f00f5e8", {
        rejectOnEmpty: error,
      })
    ).rejects.toThrow(error);
  });

  it("should load state as a fallback when content is empty", async () => {
    const state = Buffer.from([1, 2, 3]);
    const document = await buildDocument();
    await Document.unscoped().update(
      { content: null, state },
      { where: { id: document.id }, hooks: false, silent: true }
    );

    const response = await Document.findByPk(document.id);
    expect(response?.state).toEqual(state);
  });

  it("should not load state when content is available", async () => {
    const document = await buildDocument();
    const response = await Document.findByPk(document.id);
    expect(response?.content).toBeTruthy();
    expect(response?.state).toBeNull();
  });
});

describe("#withMembershipScope", () => {
  it("should not load text for documents with content", async () => {
    const user = await buildUser();
    const document = await buildDocument({
      userId: user.id,
      teamId: user.teamId,
      text: "Hello world",
    });

    const [loaded] = await Document.withMembershipScope(user.id).findAll({
      where: { id: document.id },
    });

    expect(loaded.content).toBeTruthy();
    expect(loaded.text).toBeNull();
  });

  it("should load text for documents without content or state", async () => {
    const user = await buildUser();
    const document = await buildDocument({
      userId: user.id,
      teamId: user.teamId,
      text: "Hello world",
    });
    await sequelize.query(
      `UPDATE documents SET content = NULL, state = NULL WHERE id = :id`,
      { replacements: { id: document.id } }
    );

    const [loaded] = await Document.withMembershipScope(user.id).findAll({
      where: { id: document.id },
    });

    expect(loaded.text).toEqual("Hello world");
  });
});

describe("findByIds", () => {
  it("should return documents by ids", async () => {
    const document1 = await buildDocument();
    const document2 = await buildDocument();
    const documents = await Document.findByIds([document1.id, document2.id]);
    expect(documents.length).toBe(2);
  });

  it("should return documents filtered to user access", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const document1 = await buildDocument({ teamId: team.id });
    const document2 = await buildDocument({ teamId: team.id });
    const document3 = await buildDocument();
    const documents = await Document.findByIds(
      [document1.id, document2.id, document3.id],
      {
        userId: user.id,
      }
    );
    expect(documents.length).toBe(2);
  });

  it("should return documents filtered to private collection access", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const collection = await buildCollection({
      teamId: team.id,
      permission: null,
    });
    const document1 = await buildDocument({
      teamId: team.id,
      collectionId: collection.id,
    });
    const document2 = await buildDocument({ teamId: team.id });
    const document3 = await buildDocument();
    const documents = await Document.findByIds(
      [document1.id, document2.id, document3.id],
      {
        userId: user.id,
      }
    );
    expect(documents.length).toBe(1);
  });

  it("should return documents filtered to guest access", async () => {
    const team = await buildTeam();
    const user = await buildGuestUser({ teamId: team.id });
    const document1 = await buildDocument({ teamId: team.id });
    const collection = await buildCollection({ teamId: team.id });
    await UserMembership.create({
      createdById: user.id,
      collectionId: collection.id,
      userId: user.id,
      permission: CollectionPermission.Read,
    });
    const document2 = await buildDocument({
      teamId: team.id,
      collectionId: collection.id,
    });
    const document3 = await buildDocument();
    const documents = await Document.findByIds(
      [document1.id, document2.id, document3.id],
      {
        userId: user.id,
      }
    );
    expect(documents.length).toBe(1);
  });

  it("should not return another user's unfiled draft", async () => {
    const team = await buildTeam();
    const author = await buildUser({ teamId: team.id });
    const user = await buildUser({ teamId: team.id });
    const draft = await buildDraftDocument({
      teamId: team.id,
      userId: author.id,
      collectionId: null,
    });
    const documents = await Document.findByIds([draft.id], {
      userId: user.id,
    });
    expect(documents.length).toBe(0);
  });

  it("should return the user's own unfiled draft", async () => {
    const team = await buildTeam();
    const user = await buildUser({ teamId: team.id });
    const draft = await buildDraftDocument({
      teamId: team.id,
      userId: user.id,
      collectionId: null,
    });
    const documents = await Document.findByIds([draft.id], {
      userId: user.id,
    });
    expect(documents.length).toBe(1);
  });

  it("should return an unfiled draft shared with the user", async () => {
    const team = await buildTeam();
    const author = await buildUser({ teamId: team.id });
    const user = await buildUser({ teamId: team.id });
    const draft = await buildDraftDocument({
      teamId: team.id,
      userId: author.id,
      collectionId: null,
    });
    await UserMembership.create({
      createdById: author.id,
      documentId: draft.id,
      userId: user.id,
      permission: DocumentPermission.Read,
    });
    const documents = await Document.findByIds([draft.id], {
      userId: user.id,
    });
    expect(documents.length).toBe(1);
  });
});

describe("tasks", () => {
  it("should return tasks keys set to 0 if check items isn't present", async () => {
    const document = await buildDocument({
      text: `text`,
    });
    const tasks = document.tasks;
    expect(tasks.completed).toBe(0);
    expect(tasks.total).toBe(0);
  });

  it("should return tasks keys set to 0 if the text contains broken check items", async () => {
    const document = await buildDocument({
      text: `
- [x ] test
- [ x ] test
- [  ] test`,
    });
    const tasks = document.tasks;
    expect(tasks.completed).toBe(0);
    expect(tasks.total).toBe(0);
  });

  it("should return tasks", async () => {
    const document = await buildDocument({
      text: `
- [x] list item
- [ ] list item`,
    });
    const tasks = document.tasks;
    expect(tasks.completed).toBe(1);
    expect(tasks.total).toBe(2);
  });

  it("should update tasks on save", async () => {
    const document = await buildDocument({
      text: `
- [x] list item
- [ ] list item`,
    });
    const tasks = document.tasks;
    expect(tasks.completed).toBe(1);
    expect(tasks.total).toBe(2);
    document.content = parser
      .parse(
        `
- [x] list item
- [ ] list item
- [ ] list item`
      )
      ?.toJSON();
    await document.save();
    const newTasks = document.tasks;
    expect(newTasks.completed).toBe(1);
    expect(newTasks.total).toBe(3);
  });
});

describe("commentCount", () => {
  it("returns 0 for a document with no comments", async () => {
    const document = await buildDocument();
    expect(await document.commentCount).toEqual(0);
  });

  it("counts unresolved threads and their replies", async () => {
    const document = await buildDocument();
    const thread = await buildComment({
      documentId: document.id,
      userId: document.createdById,
    });
    await buildComment({
      documentId: document.id,
      userId: document.createdById,
      parentCommentId: thread.id,
    });
    expect(await document.commentCount).toEqual(2);
  });

  it("excludes resolved threads and their replies", async () => {
    const document = await buildDocument();
    const user = await buildUser({ teamId: document.teamId });
    const resolved = await buildResolvedComment(user, {
      documentId: document.id,
      userId: user.id,
    });
    await buildComment({
      documentId: document.id,
      userId: user.id,
      parentCommentId: resolved.id,
    });
    await buildComment({
      documentId: document.id,
      userId: user.id,
    });
    expect(await document.commentCount).toEqual(1);
  });

  it("invalidates the cached count when a comment is destroyed", async () => {
    const document = await buildDocument();
    const comment = await buildComment({
      documentId: document.id,
      userId: document.createdById,
    });
    await buildComment({
      documentId: document.id,
      userId: document.createdById,
    });

    expect(await document.commentCount).toEqual(2);

    await comment.destroy();

    expect(await document.commentCount).toEqual(1);
  });

  it("invalidates the cached count when a thread is resolved", async () => {
    const document = await buildDocument();
    const user = await buildUser({ teamId: document.teamId });
    const thread = await buildComment({
      documentId: document.id,
      userId: user.id,
    });
    await buildComment({
      documentId: document.id,
      userId: user.id,
      parentCommentId: thread.id,
    });

    // Prime the cache.
    expect(await document.commentCount).toEqual(2);

    thread.resolve(user);
    await thread.save();

    expect(await document.commentCount).toEqual(0);
  });

  it("invalidates the cached count when a resolved thread is unresolved", async () => {
    const document = await buildDocument();
    const user = await buildUser({ teamId: document.teamId });
    const thread = await buildResolvedComment(user, {
      documentId: document.id,
      userId: user.id,
    });

    // Prime the cache (resolved thread is excluded).
    expect(await document.commentCount).toEqual(0);

    thread.unresolve();
    await thread.save();

    expect(await document.commentCount).toEqual(1);
  });
});