Files
outline/server/queues/tasks/DuplicateCollectionDocumentsTask.test.ts
T
b2b41dd3ca fix: Remap internal links when duplicating a document tree (#13270)
* fix: Remap internal links when duplicating a document tree

Duplicating a document tree now assigns the identifiers of every duplicate
before any content is written, so links and mentions between the documents
being duplicated point at the copies rather than the originals.

* fix: Remap fully qualified links when duplicating a document tree

Links written as a full url to this installation are now remapped alongside
relative ones, and stay fully qualified. Where a link is displayed as its own
url the text is updated to match.

Also trims surrounding whitespace in sanitizeUrl, which otherwise failed
validation and prepended a second scheme to an already qualified url.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: Remap links across trees when duplicating a collection

The collection duplication task duplicated each root document separately, so
only links within a single tree were remapped. It now duplicates the whole
collection as one unit, with identifiers assigned across every tree before any
content is written.

* fix: Match document links by identifier rather than host when duplicating

An installation can be reached through more than one host, so comparing a
link's host against the configured url left fully qualified links to a
document in the duplicated set unmapped. The document a link identifies now
decides whether it is replaced, and the host it was written with is kept.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:44:06 -04:00

175 lines
4.7 KiB
TypeScript

import { Document } from "@server/models";
import {
buildCollection,
buildDocument,
buildDraftDocument,
buildUser,
} from "@server/test/factories";
import DuplicateCollectionDocumentsTask from "./DuplicateCollectionDocumentsTask";
describe("DuplicateCollectionDocumentsTask", () => {
it("should duplicate documents into the collection", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const parent = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
parentDocumentId: parent.id,
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
expect(documents).toHaveLength(2);
const duplicatedParent = documents.find((d) => !d.parentDocumentId);
const duplicatedChild = documents.find((d) => !!d.parentDocumentId);
expect(duplicatedParent?.title).toEqual(parent.title);
expect(duplicatedParent?.publishedAt).toBeTruthy();
expect(duplicatedChild?.parentDocumentId).toEqual(duplicatedParent?.id);
});
it("should remap links between documents in different trees", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const second = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
title: "second",
});
const first = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
title: "first",
text: [
`Relative [second](${second.path}).`,
`Qualified [second](https://wiki.example.com${second.path}).`,
].join("\n\n"),
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
const duplicatedFirst = documents.find(
(d) => d.sourceMetadata?.originalDocumentId === first.id
);
const duplicatedSecond = documents.find(
(d) => d.sourceMetadata?.originalDocumentId === second.id
);
expect(duplicatedFirst?.text).toContain(`(${duplicatedSecond?.path})`);
expect(duplicatedFirst?.text).toContain(
`(https://wiki.example.com${duplicatedSecond?.path})`
);
expect(duplicatedFirst?.text).not.toContain(second.urlId);
});
it("should not duplicate drafts", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await buildDraftDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
expect(documents).toHaveLength(1);
});
it("should do nothing when the collection has been deleted", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
deletedAt: new Date(),
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
expect(documents).toHaveLength(0);
});
});