fix: stop cyclic descendant traversal before side effects

This commit is contained in:
Philipinho
2026-09-02 12:26:27 +01:00
parent e3843e7178
commit 9dad90c142
4 changed files with 453 additions and 101 deletions
@@ -133,24 +133,27 @@ export class PageService {
ydoc = createYdocFromJson(prosemirrorJson); ydoc = createYdocFromJson(prosemirrorJson);
} }
const page = await this.pageRepo.insertPage({ const page = await this.pageRepo.insertPage(
slugId: generateSlugId(), {
title: createPageDto.title, slugId: generateSlugId(),
position: await this.nextPagePosition( title: createPageDto.title,
createPageDto.spaceId, position: await this.nextPagePosition(
parentPageId, createPageDto.spaceId,
), parentPageId,
icon: createPageDto.icon, ),
parentPageId: parentPageId, icon: createPageDto.icon,
spaceId: createPageDto.spaceId, parentPageId: parentPageId,
creatorId: userId, spaceId: createPageDto.spaceId,
workspaceId: workspaceId, creatorId: userId,
lastUpdatedById: userId, workspaceId: workspaceId,
isBase, lastUpdatedById: userId,
content, isBase,
textContent, content,
ydoc, textContent,
}, trx); ydoc,
},
trx,
);
if (trx) { if (trx) {
// Add the watcher inside the caller's transaction so the async worker // Add the watcher inside the caller's transaction so the async worker
@@ -1034,19 +1037,29 @@ export class PageService {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', pageId) .where('id', '=', pageId)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select([
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), 'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id', 'isCycle'])
.execute(); .execute();
assertAcyclicPageTraversal(descendants, pageId);
const pageIds = descendants.map((d) => d.id); const pageIds = descendants.map((d) => d.id);
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates // Queue attachment deletion for all pages with unique job IDs to prevent duplicates
@@ -5,6 +5,8 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { assertAcyclicPageTraversal } from '../../../database/helpers/page-hierarchy-cycle';
import { sql } from 'kysely';
const DEFAULT_RETENTION_DAYS = 30; const DEFAULT_RETENTION_DAYS = 30;
@@ -76,19 +78,29 @@ export class TrashCleanupService {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', pageId) .where('id', '=', pageId)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select([
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), 'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id', 'isCycle'])
.execute(); .execute();
assertAcyclicPageTraversal(descendants, pageId);
const pageIds = descendants.map((d) => d.id); const pageIds = descendants.map((d) => d.id);
this.logger.debug( this.logger.debug(
+139 -72
View File
@@ -16,6 +16,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants'; import { EventName } from '../../../common/events/event.contants';
import {
assertAcyclicPageTraversal,
stripPageTraversalMetadata,
} from '../../helpers/page-hierarchy-cycle';
@Injectable() @Injectable()
export class PageRepo { export class PageRepo {
@@ -203,21 +207,31 @@ export class PageRepo {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', pageId) .where('id', '=', pageId)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select([
'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId') .innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('p.deletedAt', 'is', null), .where('p.deletedAt', 'is', null)
.where('pd.isCycle', '=', false),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id', 'isCycle'])
.execute(); .execute();
assertAcyclicPageTraversal(descendants, pageId);
const pageIds = descendants.map((d) => d.id); const pageIds = descendants.map((d) => d.id);
if (pageIds.length > 0) { if (pageIds.length > 0) {
@@ -272,19 +286,29 @@ export class PageRepo {
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
.select(['id']) .select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', pageId) .where('id', '=', pageId)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
.select(['p.id']) .select([
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'), 'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
), ),
) )
.selectFrom('page_descendants') .selectFrom('page_descendants')
.selectAll() .select(['id', 'isCycle'])
.execute(); .execute();
assertAcyclicPageTraversal(pages, pageId);
const pageIds = pages.map((p) => p.id); const pageIds = pages.map((p) => p.id);
// Restore all pages, but only detach the root page if its parent is deleted // Restore all pages, but only detach the root page if its parent is deleted
@@ -354,7 +378,12 @@ export class PageRepo {
}); });
} }
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) { async getCreatedByPages(
creatorId: string,
requestingUserId: string,
pagination: PaginationOptions,
spaceId?: string,
) {
let query = this.db let query = this.db
.selectFrom('pages') .selectFrom('pages')
.select(this.baseFields) .select(this.baseFields)
@@ -365,7 +394,11 @@ export class PageRepo {
if (spaceId) { if (spaceId) {
query = query.where('spaceId', '=', spaceId); query = query.where('spaceId', '=', spaceId);
} else { } else {
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId)); query = query.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId),
);
} }
return executeWithCursorPagination(query, { return executeWithCursorPagination(query, {
@@ -491,7 +524,7 @@ export class PageRepo {
parentPageId: string, parentPageId: string,
opts: { includeContent: boolean }, opts: { includeContent: boolean },
) { ) {
return this.db const pages = await this.db
.withRecursive('page_hierarchy', (db) => .withRecursive('page_hierarchy', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
@@ -506,6 +539,8 @@ export class PageRepo {
'workspaceId', 'workspaceId',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
]) ])
.$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId) .where('id', '=', parentPageId)
@@ -524,15 +559,34 @@ export class PageRepo {
'p.workspaceId', 'p.workspaceId',
'p.createdAt', 'p.createdAt',
'p.updatedAt', 'p.updatedAt',
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
]) ])
.$if(opts?.includeContent, (qb) => qb.select('p.content')) .$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.where('p.deletedAt', 'is', null), .where('p.deletedAt', 'is', null)
.where('ph.isCycle', '=', false),
), ),
) )
.selectFrom('page_hierarchy') .selectFrom('page_hierarchy')
.selectAll() .select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
'isCycle',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.execute(); .execute();
assertAcyclicPageTraversal(pages, parentPageId);
return pages.map((page) => stripPageTraversalMetadata(page));
} }
/** /**
@@ -540,69 +594,82 @@ export class PageRepo {
* More efficient than getPageAndDescendants + filtering because: * More efficient than getPageAndDescendants + filtering because:
* 1. Single DB query (no separate restricted IDs query) * 1. Single DB query (no separate restricted IDs query)
* 2. Stops traversing at restricted pages (doesn't fetch data to discard) * 2. Stops traversing at restricted pages (doesn't fetch data to discard)
* 3. No in-memory filtering needed * 3. Filters the bounded traversal only after hierarchy validation
*/ */
async getPageAndDescendantsExcludingRestricted( async getPageAndDescendantsExcludingRestricted(
parentPageId: string, parentPageId: string,
opts: { includeContent: boolean }, opts: { includeContent: boolean },
) { ) {
return ( const pages = await this.db
this.db .withRecursive('page_hierarchy', (db) =>
.withRecursive('page_hierarchy', (db) => db
db .selectFrom('pages')
.selectFrom('pages') .leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id') .select([
.select([ 'pages.id',
'pages.id', 'pages.slugId',
'pages.slugId', 'pages.title',
'pages.title', 'pages.icon',
'pages.icon', 'pages.position',
'pages.position', 'pages.parentPageId',
'pages.parentPageId', 'pages.spaceId',
'pages.spaceId', 'pages.workspaceId',
'pages.workspaceId', sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'), sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
]) sql<boolean>`false`.as('isCycle'),
.$if(opts?.includeContent, (qb) => qb.select('pages.content')) ])
.where('pages.id', '=', parentPageId) .$if(opts?.includeContent, (qb) => qb.select('pages.content'))
.where('pages.deletedAt', 'is', null) .where('pages.id', '=', parentPageId)
.unionAll((exp) => .where('pages.deletedAt', 'is', null)
exp .unionAll((exp) =>
.selectFrom('pages as p') exp
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') .selectFrom('pages as p')
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id') .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.select([ .leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
'p.id', .select([
'p.slugId', 'p.id',
'p.title', 'p.slugId',
'p.icon', 'p.title',
'p.position', 'p.icon',
'p.parentPageId', 'p.position',
'p.spaceId', 'p.parentPageId',
'p.workspaceId', 'p.spaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'), 'p.workspaceId',
]) sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
.$if(opts?.includeContent, (qb) => qb.select('p.content')) sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
.where('p.deletedAt', 'is', null) sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
// Only recurse into children of non-restricted pages ])
.where('ph.isRestricted', '=', false), .$if(opts?.includeContent, (qb) => qb.select('p.content'))
), .where('p.deletedAt', 'is', null)
) // Only recurse into children of non-restricted pages
.selectFrom('page_hierarchy') .where('ph.isRestricted', '=', false)
.select([ .where('ph.isCycle', '=', false),
'id', ),
'slugId', )
'title', .selectFrom('page_hierarchy')
'icon', .select([
'position', 'id',
'parentPageId', 'slugId',
'spaceId', 'title',
'workspaceId', 'icon',
]) 'position',
.$if(opts?.includeContent, (qb) => qb.select('content')) 'parentPageId',
// Filter out restricted pages from the result 'spaceId',
.where('isRestricted', '=', false) 'workspaceId',
.execute() 'isRestricted',
); 'isCycle',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.execute();
assertAcyclicPageTraversal(pages, parentPageId);
return pages
.filter((page) => !page.isRestricted)
.map((page) => {
const withoutCycleMetadata = stripPageTraversalMetadata(page);
const { isRestricted: _isRestricted, ...publicPage } =
withoutCycleMetadata;
return publicPage;
});
} }
} }
@@ -1,9 +1,12 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Logger } from '@nestjs/common';
import { createCache } from 'cache-manager'; import { createCache } from 'cache-manager';
import { PageService } from '../src/core/page/services/page.service'; import { PageService } from '../src/core/page/services/page.service';
import { TrashCleanupService } from '../src/core/page/services/trash-cleanup.service';
import { ShareService } from '../src/core/share/share.service'; import { ShareService } from '../src/core/share/share.service';
import { PageHierarchyCycleError } from '../src/database/helpers/page-hierarchy-cycle'; import { PageHierarchyCycleError } from '../src/database/helpers/page-hierarchy-cycle';
import { PagePermissionRepo } from '../src/database/repos/page/page-permission.repo'; import { PagePermissionRepo } from '../src/database/repos/page/page-permission.repo';
import { PageRepo } from '../src/database/repos/page/page.repo';
import { KyselyDB } from '../src/database/types/kysely.types'; import { KyselyDB } from '../src/database/types/kysely.types';
import { db, withStatementTimeout } from './support/database'; import { db, withStatementTimeout } from './support/database';
import { import {
@@ -13,23 +16,36 @@ import {
seedTwoPageCycle, seedTwoPageCycle,
} from './support/page-hierarchy-fixtures'; } from './support/page-hierarchy-fixtures';
function createPageService(connection: KyselyDB): PageService { function createPageService(
connection: KyselyDB,
dependencies: {
attachmentQueue?: { add: jest.Mock };
eventEmitter?: { emit: jest.Mock };
} = {},
): PageService {
return new PageService( return new PageService(
undefined as never, undefined as never,
undefined as never, undefined as never,
undefined as never, undefined as never,
connection, connection,
undefined as never, undefined as never,
dependencies.attachmentQueue as never,
undefined as never, undefined as never,
undefined as never, undefined as never,
undefined as never, dependencies.eventEmitter as never,
undefined as never,
undefined as never, undefined as never,
undefined as never, undefined as never,
undefined as never, undefined as never,
); );
} }
function createPageRepo(
connection: KyselyDB,
eventEmitter: { emit: jest.Mock } = { emit: jest.fn() },
): PageRepo {
return new PageRepo(connection, undefined as never, eventEmitter as never);
}
function createShareService(connection: KyselyDB): ShareService { function createShareService(connection: KyselyDB): ShareService {
return new ShareService( return new ShareService(
undefined as never, undefined as never,
@@ -167,7 +183,251 @@ async function insertShare(pageId: string, includeSubPages: boolean) {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
async function expectPageHierarchyCycle(
operation: Promise<unknown>,
rootPageId: string,
): Promise<void> {
const error = await operation.then(
() => undefined,
(reason: unknown) => reason,
);
expect(error).toBeInstanceOf(PageHierarchyCycleError);
expect(error).toEqual(
expect.objectContaining({
code: 'PAGE_HIERARCHY_CYCLE',
rootPageId,
}) satisfies Partial<PageHierarchyCycleError>,
);
}
afterEach(() => {
jest.restoreAllMocks();
});
describe('cycle-safe page hierarchy reads', () => { describe('cycle-safe page hierarchy reads', () => {
describe('descendant traversal', () => {
it('returns every page in an acyclic branching tree exactly once without internal metadata', async () => {
const { root, firstChild, secondChild, grandchild } =
await seedBranchingDescendantTree();
const repo = createPageRepo(db);
const pages = await repo.getPageAndDescendants(root.id, {
includeContent: false,
});
expect(pages.map((page) => page.id).sort()).toEqual(
[root.id, firstChild.id, secondChild.id, grandchild.id].sort(),
);
expect(pages).toHaveLength(4);
for (const page of pages) {
expect(page).not.toHaveProperty('isCycle');
expect(page).not.toHaveProperty('traversalPath');
}
});
describe.each([
['a self-cycle', async () => (await seedSelfCycle()).self.id],
['a two-page cycle', async () => (await seedTwoPageCycle()).a.id],
])('%s', (_cycleName, seedCycle) => {
it('raises PageHierarchyCycleError from the structural descendant read', async () => {
const pageId = await seedCycle();
await withStatementTimeout(async (connection) => {
const repo = createPageRepo(connection);
await expectPageHierarchyCycle(
repo.getPageAndDescendants(pageId, { includeContent: false }),
pageId,
);
});
});
it('raises PageHierarchyCycleError from the restricted descendant read', async () => {
const pageId = await seedCycle();
await withStatementTimeout(async (connection) => {
const repo = createPageRepo(connection);
await expectPageHierarchyCycle(
repo.getPageAndDescendantsExcludingRestricted(pageId, {
includeContent: false,
}),
pageId,
);
});
});
});
it('preserves restricted-subtree exclusion for an acyclic tree', async () => {
const { root, firstChild, secondChild, grandchild } =
await seedBranchingDescendantTree();
await restrictPage(firstChild.id);
const repo = createPageRepo(db);
const pages = await repo.getPageAndDescendantsExcludingRestricted(
root.id,
{ includeContent: false },
);
expect(pages.map((page) => page.id).sort()).toEqual(
[root.id, secondChild.id].sort(),
);
expect(pages.map((page) => page.id)).not.toContain(firstChild.id);
expect(pages.map((page) => page.id)).not.toContain(grandchild.id);
for (const page of pages) {
expect(page).not.toHaveProperty('isCycle');
expect(page).not.toHaveProperty('isRestricted');
expect(page).not.toHaveProperty('traversalPath');
}
});
it('removePage leaves pages and shares unchanged and emits no event on a cycle', async () => {
const { a, b } = await seedTwoPageCycle();
const deletedById = await insertTestUser(a.id);
const share = await insertShare(b.id, true);
const eventEmitter = { emit: jest.fn() };
await withStatementTimeout(async (connection) => {
const repo = createPageRepo(connection, eventEmitter);
await expectPageHierarchyCycle(
repo.removePage(a.id, deletedById, share.workspaceId),
a.id,
);
});
const storedPages = await db
.selectFrom('pages')
.select(['id', 'deletedAt', 'deletedById'])
.where('id', 'in', [a.id, b.id])
.orderBy('id')
.execute();
const storedShare = await db
.selectFrom('shares')
.select('id')
.where('id', '=', share.id)
.executeTakeFirst();
expect(storedPages).toEqual(
[a.id, b.id]
.sort()
.map((id) => ({ id, deletedAt: null, deletedById: null })),
);
expect(storedShare).toEqual({ id: share.id });
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('restorePage leaves deleted pages unchanged and emits no event on a cycle', async () => {
const { a, b } = await seedTwoPageCycle();
const deletedAt = new Date('2024-01-02T03:04:05.000Z');
await db
.updateTable('pages')
.set({ deletedAt })
.where('id', 'in', [a.id, b.id])
.execute();
const eventEmitter = { emit: jest.fn() };
await withStatementTimeout(async (connection) => {
const repo = createPageRepo(connection, eventEmitter);
await expectPageHierarchyCycle(
repo.restorePage(a.id, randomUUID()),
a.id,
);
});
const storedPages = await db
.selectFrom('pages')
.select(['id', 'parentPageId', 'deletedAt'])
.where('id', 'in', [a.id, b.id])
.orderBy('id')
.execute();
expect(storedPages).toEqual(
[
{ id: a.id, parentPageId: b.id, deletedAt },
{ id: b.id, parentPageId: a.id, deletedAt },
].sort((left, right) => left.id.localeCompare(right.id)),
);
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('forceDelete leaves pages intact and enqueues and emits nothing on a cycle', async () => {
const { a, b } = await seedTwoPageCycle();
const attachmentQueue = { add: jest.fn() };
const eventEmitter = { emit: jest.fn() };
await withStatementTimeout(async (connection) => {
const pageService = createPageService(connection, {
attachmentQueue,
eventEmitter,
});
await expectPageHierarchyCycle(
pageService.forceDelete(a.id, randomUUID()),
a.id,
);
});
const storedPageIds = await db
.selectFrom('pages')
.select('id')
.where('id', 'in', [a.id, b.id])
.orderBy('id')
.execute();
expect(storedPageIds).toEqual([a.id, b.id].sort().map((id) => ({ id })));
expect(attachmentQueue.add).not.toHaveBeenCalled();
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('trash cleanup logs and skips a corrupt root before continuing with acyclic trash', async () => {
const { self } = await seedSelfCycle();
const { root, child, grandchild } = await seedAcyclicPageChain();
const expiredAt = new Date('2024-01-02T03:04:05.000Z');
await db
.updateTable('pages')
.set({ deletedAt: expiredAt })
.where('id', 'in', [self.id, root.id])
.execute();
const attachmentQueue = { add: jest.fn() };
const loggerError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
await withStatementTimeout(async (connection) => {
const timeoutCleanupService = new TrashCleanupService(
connection,
attachmentQueue as never,
);
await timeoutCleanupService.cleanupOldTrash();
});
const corruptPage = await db
.selectFrom('pages')
.select(['id', 'deletedAt'])
.where('id', '=', self.id)
.executeTakeFirst();
const cleanedPageIds = await db
.selectFrom('pages')
.select('id')
.where('id', 'in', [root.id, child.id, grandchild.id])
.execute();
expect(corruptPage).toEqual({ id: self.id, deletedAt: expiredAt });
expect(cleanedPageIds).toEqual([]);
expect(attachmentQueue.add).toHaveBeenCalledTimes(3);
expect(
attachmentQueue.add.mock.calls.map(([, payload]) => payload.pageId),
).toEqual(expect.arrayContaining([root.id, child.id, grandchild.id]));
expect(loggerError).toHaveBeenCalledWith(
expect.stringContaining(`Failed to cleanup page ${self.id}`),
expect.any(String),
);
});
});
describe('PageService.getPageBreadCrumbs', () => { describe('PageService.getPageBreadCrumbs', () => {
it('returns every acyclic breadcrumb exactly once in root-to-child order', async () => { it('returns every acyclic breadcrumb exactly once in root-to-child order', async () => {
const { root, child, grandchild } = await seedAcyclicPageChain(); const { root, child, grandchild } = await seedAcyclicPageChain();