mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 10:05:03 +08:00
feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BacklinkService } from './backlink.service';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
describe('BacklinkService.countByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('returns post-filter counts for both directions', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockImplementation(async (_id, dir) =>
|
||||
dir === 'incoming' ? ['a', 'b', 'c'] : ['x', 'y'],
|
||||
);
|
||||
permissionRepo.filterAccessiblePageIds.mockImplementation(
|
||||
async ({ pageIds }) =>
|
||||
pageIds.filter((id) => id !== 'b' && id !== 'y'),
|
||||
);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 2, outgoing: 1 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['a', 'b', 'c'],
|
||||
userId,
|
||||
});
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['x', 'y'],
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the permission filter when there are no candidates', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue([]);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 0, outgoing: 0 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the userId to findRelatedPageIds so the repo can apply space membership filtering', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
|
||||
await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'incoming',
|
||||
userId,
|
||||
);
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'outgoing',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BacklinkService.findByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('passes filtered ids through to the paginated repo call', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue(['a', 'b']);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue(['a']);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
['a'],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hands an empty list to the repo when there are no accessible ids', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
export type BacklinkDirection = 'incoming' | 'outgoing';
|
||||
|
||||
@Injectable()
|
||||
export class BacklinkService {
|
||||
constructor(
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async countByPageId(
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<{ incoming: number; outgoing: number }> {
|
||||
const [incomingIds, outgoingIds] = await Promise.all([
|
||||
this.accessibleRelatedIds(pageId, 'incoming', userId),
|
||||
this.accessibleRelatedIds(pageId, 'outgoing', userId),
|
||||
]);
|
||||
return { incoming: incomingIds.length, outgoing: outgoingIds.length };
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const accessibleIds = await this.accessibleRelatedIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
return this.backlinkRepo.findPagesByIdsPaginated(accessibleIds, pagination);
|
||||
}
|
||||
|
||||
private async accessibleRelatedIds(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const candidateIds = await this.backlinkRepo.findRelatedPageIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
if (candidateIds.length === 0) return [];
|
||||
return this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidateIds,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user