server/models/Document.test.ts
Rama main · réplica de lectura del repositorio original.
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);
});
});