Files
docmost/apps/server/test/page-hierarchy-cycle.integration-spec.ts
T

229 lines
7.1 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { PageService } from '../src/core/page/services/page.service';
import { ShareService } from '../src/core/share/share.service';
import { PageHierarchyCycleError } from '../src/database/helpers/page-hierarchy-cycle';
import { KyselyDB } from '../src/database/types/kysely.types';
import { db, withStatementTimeout } from './support/database';
import {
seedAcyclicPageChain,
seedSelfCycle,
seedTwoPageCycle,
} from './support/page-hierarchy-fixtures';
function createPageService(connection: KyselyDB): PageService {
return new PageService(
undefined as never,
undefined as never,
undefined as never,
connection,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
);
}
function createShareService(connection: KyselyDB): ShareService {
return new ShareService(
undefined as never,
undefined as never,
undefined as never,
connection,
undefined as never,
undefined as never,
);
}
async function insertShare(pageId: string, includeSubPages: boolean) {
const page = await db
.selectFrom('pages')
.select(['spaceId', 'workspaceId'])
.where('id', '=', pageId)
.executeTakeFirstOrThrow();
return db
.insertInto('shares')
.values({
includeSubPages,
key: `cycle-test-${randomUUID()}`,
pageId,
searchIndexing: false,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
})
.returning(['id', 'workspaceId'])
.executeTakeFirstOrThrow();
}
describe('cycle-safe page hierarchy reads', () => {
describe('PageService.getPageBreadCrumbs', () => {
it('returns every acyclic breadcrumb exactly once in root-to-child order', async () => {
const { root, child, grandchild } = await seedAcyclicPageChain();
const pageService = createPageService(db);
const breadcrumbs = await pageService.getPageBreadCrumbs(grandchild.id);
expect(breadcrumbs.map(({ id, title }) => ({ id, title }))).toEqual([
{ id: root.id, title: 'Root' },
{ id: child.id, title: 'Child' },
{ id: grandchild.id, title: 'Grandchild' },
]);
expect(breadcrumbs).toHaveLength(3);
for (const breadcrumb of breadcrumbs) {
expect(breadcrumb).not.toHaveProperty('isCycle');
expect(breadcrumb).not.toHaveProperty('traversalPath');
}
});
it('raises PageHierarchyCycleError for a self-cycle', async () => {
const { self } = await seedSelfCycle();
await withStatementTimeout(async (connection) => {
const pageService = createPageService(connection);
const breadcrumbs = pageService.getPageBreadCrumbs(self.id);
await expect(breadcrumbs).rejects.toBeInstanceOf(
PageHierarchyCycleError,
);
await expect(breadcrumbs).rejects.toEqual(
expect.objectContaining({
code: 'PAGE_HIERARCHY_CYCLE',
rootPageId: self.id,
}) satisfies Partial<PageHierarchyCycleError>,
);
});
});
it('raises PageHierarchyCycleError for a two-page cycle', async () => {
const { a } = await seedTwoPageCycle();
await withStatementTimeout(async (connection) => {
const pageService = createPageService(connection);
const breadcrumbs = pageService.getPageBreadCrumbs(a.id);
await expect(breadcrumbs).rejects.toBeInstanceOf(
PageHierarchyCycleError,
);
await expect(breadcrumbs).rejects.toEqual(
expect.objectContaining({
code: 'PAGE_HIERARCHY_CYCLE',
rootPageId: a.id,
}) satisfies Partial<PageHierarchyCycleError>,
);
});
});
});
describe('ShareService.getShareForPage', () => {
it('returns the nearest valid inherited share without a depth limit', async () => {
const { root, grandchild } = await seedAcyclicPageChain();
const context = await db
.selectFrom('pages')
.select(['spaceId', 'workspaceId'])
.where('id', '=', root.id)
.executeTakeFirstOrThrow();
let descendantId = grandchild.id;
let nearerSharedAncestorId: string;
for (let depth = 3; depth <= 52; depth += 1) {
const descendant = await db
.insertInto('pages')
.values({
parentPageId: descendantId,
slugId: randomUUID(),
spaceId: context.spaceId,
title: `Descendant ${depth}`,
workspaceId: context.workspaceId,
})
.returning('id')
.executeTakeFirstOrThrow();
descendantId = descendant.id;
if (depth === 26) {
nearerSharedAncestorId = descendant.id;
}
}
const fartherShare = await insertShare(root.id, true);
const nearerShare = await insertShare(nearerSharedAncestorId, true);
const shareService = createShareService(db);
const share = await shareService.getShareForPage(
descendantId,
context.workspaceId,
);
expect(share).toEqual(
expect.objectContaining({
id: nearerShare.id,
pageId: nearerSharedAncestorId,
level: 26,
}),
);
expect(share.id).not.toBe(fartherShare.id);
});
it('returns a direct share without traversing corrupt parents', async () => {
const { self } = await seedSelfCycle();
const storedShare = await insertShare(self.id, false);
await withStatementTimeout(async (connection) => {
const shareService = createShareService(connection);
await expect(
shareService.getShareForPage(self.id, storedShare.workspaceId),
).resolves.toEqual(
expect.objectContaining({
id: storedShare.id,
pageId: self.id,
level: 0,
}),
);
});
});
it('rejects a share from a different workspace', async () => {
const { root } = await seedAcyclicPageChain();
await insertShare(root.id, true);
const shareService = createShareService(db);
await expect(
shareService.getShareForPage(root.id, randomUUID()),
).resolves.toBeUndefined();
});
it('rejects an inherited share that excludes subpages', async () => {
const { root, grandchild } = await seedAcyclicPageChain();
const storedShare = await insertShare(root.id, false);
const shareService = createShareService(db);
await expect(
shareService.getShareForPage(
grandchild.id,
storedShare.workspaceId,
),
).resolves.toBeUndefined();
});
it('returns undefined for a cyclic chain with no reachable share', async () => {
const { a } = await seedTwoPageCycle();
const { workspaceId } = await db
.selectFrom('pages')
.select('workspaceId')
.where('id', '=', a.id)
.executeTakeFirstOrThrow();
await withStatementTimeout(async (connection) => {
const shareService = createShareService(connection);
await expect(
shareService.getShareForPage(a.id, workspaceId),
).resolves.toBeUndefined();
});
});
});
});