page-listing.integ.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import type { IPage, IUser } from '@growi/core/dist/interfaces';
  2. import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
  3. import type { HydratedDocument, Model } from 'mongoose';
  4. import mongoose from 'mongoose';
  5. import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
  6. import type { PageModel } from '~/server/models/page';
  7. import type { IPageOperation } from '~/server/models/page-operation';
  8. import { pageListingService } from './page-listing';
  9. // Mock the page-operation service
  10. vi.mock('~/server/service/page-operation', () => ({
  11. pageOperationService: {
  12. generateProcessInfo: vi.fn((pageOperations: IPageOperation[]) => {
  13. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  14. const processInfo: Record<string, any> = {};
  15. pageOperations.forEach((pageOp) => {
  16. const pageId = pageOp.page._id.toString();
  17. processInfo[pageId] = {
  18. [pageOp.actionType]: {
  19. [PageActionStage.Main]: { isProcessable: true },
  20. [PageActionStage.Sub]: undefined,
  21. },
  22. };
  23. });
  24. return processInfo;
  25. }),
  26. },
  27. }));
  28. describe('page-listing store integration tests', () => {
  29. let Page: PageModel;
  30. let User: Model<IUser>;
  31. let PageOperation: Model<IPageOperation>;
  32. let testUser: HydratedDocument<IUser>;
  33. let rootPage: HydratedDocument<IPage>;
  34. // Helper function to validate IPageForTreeItem type structure
  35. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  36. const validatePageForTreeItem = (page: any): void => {
  37. expect(page).toBeDefined();
  38. expect(page._id).toBeDefined();
  39. expect(typeof page.path).toBe('string');
  40. expect(page.grant).toBeDefined();
  41. expect(typeof page.isEmpty).toBe('boolean');
  42. expect(typeof page.descendantCount).toBe('number');
  43. // revision is required when isEmpty is false
  44. if (page.isEmpty === false) {
  45. expect(page.revision).toBeDefined();
  46. expect(isValidObjectId(page.revision)).toBe(true);
  47. }
  48. // processData is optional
  49. if (page.processData !== undefined) {
  50. expect(page.processData).toBeInstanceOf(Object);
  51. }
  52. };
  53. beforeAll(async () => {
  54. // setup models
  55. const setupPage = (await import('~/server/models/page')).default;
  56. setupPage(null);
  57. const setupUser = (await import('~/server/models/user')).default;
  58. setupUser(null);
  59. // get models
  60. Page = mongoose.model<IPage, PageModel>('Page');
  61. User = mongoose.model<IUser>('User');
  62. PageOperation = (await import('~/server/models/page-operation')).default;
  63. });
  64. beforeEach(async () => {
  65. // Clean up database
  66. await Page.deleteMany({});
  67. await User.deleteMany({});
  68. await PageOperation.deleteMany({});
  69. // Create test user
  70. testUser = await User.create({
  71. name: 'Test User',
  72. username: 'testuser',
  73. email: 'test@example.com',
  74. lang: 'en_US',
  75. });
  76. // Create root page
  77. rootPage = await Page.create({
  78. path: '/',
  79. revision: new mongoose.Types.ObjectId(),
  80. creator: testUser._id,
  81. lastUpdateUser: testUser._id,
  82. grant: 1, // GRANT_PUBLIC
  83. isEmpty: false,
  84. descendantCount: 0,
  85. });
  86. });
  87. describe('pageListingService.findRootByViewer', () => {
  88. test('should return root page successfully', async () => {
  89. const rootPageResult =
  90. await pageListingService.findRootByViewer(testUser);
  91. expect(rootPageResult).toBeDefined();
  92. expect(rootPageResult.path).toBe('/');
  93. expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
  94. expect(rootPageResult.grant).toBe(1);
  95. expect(rootPageResult.isEmpty).toBe(false);
  96. expect(rootPageResult.descendantCount).toBe(0);
  97. });
  98. test('should handle error when root page does not exist', async () => {
  99. // Remove the root page
  100. await Page.deleteOne({ path: '/' });
  101. try {
  102. await pageListingService.findRootByViewer(testUser);
  103. // Should not reach here
  104. expect(true).toBe(false);
  105. } catch (error) {
  106. expect(error).toBeDefined();
  107. }
  108. });
  109. test('should return proper page structure that matches IPageForTreeItem type', async () => {
  110. const rootPageResult =
  111. await pageListingService.findRootByViewer(testUser);
  112. // Use helper function to validate type structure
  113. validatePageForTreeItem(rootPageResult);
  114. // Additional type-specific validations
  115. expect(typeof rootPageResult._id).toBe('object'); // ObjectId
  116. expect(rootPageResult.path).toBe('/');
  117. expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
  118. });
  119. test('should work without user (guest access) and return type-safe result', async () => {
  120. const rootPageResult = await pageListingService.findRootByViewer();
  121. validatePageForTreeItem(rootPageResult);
  122. expect(rootPageResult.path).toBe('/');
  123. expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
  124. });
  125. });
  126. describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
  127. let childPage1: HydratedDocument<IPage>;
  128. beforeEach(async () => {
  129. // Create child pages
  130. childPage1 = await Page.create({
  131. path: '/child1',
  132. revision: new mongoose.Types.ObjectId(),
  133. creator: testUser._id,
  134. lastUpdateUser: testUser._id,
  135. grant: 1, // GRANT_PUBLIC
  136. isEmpty: false,
  137. descendantCount: 1,
  138. parent: rootPage._id,
  139. });
  140. await Page.create({
  141. path: '/child2',
  142. revision: new mongoose.Types.ObjectId(),
  143. creator: testUser._id,
  144. lastUpdateUser: testUser._id,
  145. grant: 1, // GRANT_PUBLIC
  146. isEmpty: false,
  147. descendantCount: 0,
  148. parent: rootPage._id,
  149. });
  150. // Create grandchild page
  151. await Page.create({
  152. path: '/child1/grandchild',
  153. revision: new mongoose.Types.ObjectId(),
  154. creator: testUser._id,
  155. lastUpdateUser: testUser._id,
  156. grant: 1, // GRANT_PUBLIC
  157. isEmpty: false,
  158. descendantCount: 0,
  159. parent: childPage1._id,
  160. });
  161. // Update root page descendant count
  162. await Page.updateOne({ _id: rootPage._id }, { descendantCount: 2 });
  163. });
  164. test('should find children by parent path and return type-safe results', async () => {
  165. const children =
  166. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  167. '/',
  168. testUser,
  169. );
  170. expect(children).toHaveLength(2);
  171. children.forEach((child) => {
  172. validatePageForTreeItem(child);
  173. expect(['/child1', '/child2']).toContain(child.path);
  174. });
  175. });
  176. test('should find children by parent ID and return type-safe results', async () => {
  177. const children =
  178. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  179. rootPage._id.toString(),
  180. testUser,
  181. );
  182. expect(children).toHaveLength(2);
  183. children.forEach((child) => {
  184. validatePageForTreeItem(child);
  185. });
  186. });
  187. test('should handle nested children correctly', async () => {
  188. const nestedChildren =
  189. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  190. '/child1',
  191. testUser,
  192. );
  193. expect(nestedChildren).toHaveLength(1);
  194. const grandChild = nestedChildren[0];
  195. validatePageForTreeItem(grandChild);
  196. expect(grandChild.path).toBe('/child1/grandchild');
  197. });
  198. test('should return empty array when no children exist', async () => {
  199. const noChildren =
  200. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  201. '/child2',
  202. testUser,
  203. );
  204. expect(noChildren).toHaveLength(0);
  205. expect(Array.isArray(noChildren)).toBe(true);
  206. });
  207. test('should work without user (guest access)', async () => {
  208. const children =
  209. await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
  210. expect(children).toHaveLength(2);
  211. children.forEach((child) => {
  212. validatePageForTreeItem(child);
  213. });
  214. });
  215. test('should sort children by path in ascending order', async () => {
  216. const children =
  217. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  218. '/',
  219. testUser,
  220. );
  221. expect(children).toHaveLength(2);
  222. expect(children[0].path).toBe('/child1');
  223. expect(children[1].path).toBe('/child2');
  224. });
  225. });
  226. describe('pageListingService processData injection', () => {
  227. let operatingPage: HydratedDocument<IPage>;
  228. beforeEach(async () => {
  229. // Create a page that will have operations
  230. operatingPage = await Page.create({
  231. path: '/operating-page',
  232. revision: new mongoose.Types.ObjectId(),
  233. creator: testUser._id,
  234. lastUpdateUser: testUser._id,
  235. grant: 1, // GRANT_PUBLIC
  236. isEmpty: false,
  237. descendantCount: 0,
  238. parent: rootPage._id,
  239. });
  240. // Create a PageOperation for this page
  241. await PageOperation.create({
  242. actionType: PageActionType.Rename,
  243. actionStage: PageActionStage.Main,
  244. page: {
  245. _id: operatingPage._id,
  246. path: operatingPage.path,
  247. isEmpty: operatingPage.isEmpty,
  248. grant: operatingPage.grant,
  249. grantedGroups: [],
  250. descendantCount: operatingPage.descendantCount,
  251. },
  252. user: {
  253. _id: testUser._id,
  254. },
  255. fromPath: '/operating-page',
  256. toPath: '/renamed-operating-page',
  257. options: {},
  258. });
  259. });
  260. test('should inject processData for pages with operations', async () => {
  261. const children =
  262. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  263. '/',
  264. testUser,
  265. );
  266. // Find the operating page in results
  267. const operatingResult = children.find(
  268. (child) => child.path === '/operating-page',
  269. );
  270. expect(operatingResult).toBeDefined();
  271. // Validate type structure
  272. if (operatingResult) {
  273. validatePageForTreeItem(operatingResult);
  274. // Check that processData was injected
  275. expect(operatingResult.processData).toBeDefined();
  276. expect(operatingResult.processData).toBeInstanceOf(Object);
  277. }
  278. });
  279. test('should set processData to undefined for pages without operations', async () => {
  280. // Create another page without operations
  281. await Page.create({
  282. path: '/normal-page',
  283. revision: new mongoose.Types.ObjectId(),
  284. creator: testUser._id,
  285. lastUpdateUser: testUser._id,
  286. grant: 1, // GRANT_PUBLIC
  287. isEmpty: false,
  288. descendantCount: 0,
  289. parent: rootPage._id,
  290. });
  291. const children =
  292. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  293. '/',
  294. testUser,
  295. );
  296. const normalPage = children.find(
  297. (child) => child.path === '/normal-page',
  298. );
  299. expect(normalPage).toBeDefined();
  300. if (normalPage) {
  301. validatePageForTreeItem(normalPage);
  302. expect(normalPage.processData).toBeUndefined();
  303. }
  304. });
  305. test('should maintain type safety with mixed processData scenarios', async () => {
  306. // Create pages with and without operations
  307. await Page.create({
  308. path: '/mixed-test-1',
  309. revision: new mongoose.Types.ObjectId(),
  310. creator: testUser._id,
  311. lastUpdateUser: testUser._id,
  312. grant: 1, // GRANT_PUBLIC
  313. isEmpty: false,
  314. descendantCount: 0,
  315. parent: rootPage._id,
  316. });
  317. await Page.create({
  318. path: '/mixed-test-2',
  319. revision: new mongoose.Types.ObjectId(),
  320. creator: testUser._id,
  321. lastUpdateUser: testUser._id,
  322. grant: 1, // GRANT_PUBLIC
  323. isEmpty: false,
  324. descendantCount: 0,
  325. parent: rootPage._id,
  326. });
  327. const children =
  328. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  329. '/',
  330. testUser,
  331. );
  332. // All results should be type-safe regardless of processData presence
  333. children.forEach((child) => {
  334. validatePageForTreeItem(child);
  335. // processData should be either undefined or a valid object
  336. if (child.processData !== undefined) {
  337. expect(child.processData).toBeInstanceOf(Object);
  338. }
  339. });
  340. });
  341. });
  342. describe('PageQueryBuilder exec() type safety tests', () => {
  343. test('findRootByViewer should return object with correct _id type', async () => {
  344. const result = await pageListingService.findRootByViewer(testUser);
  345. // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
  346. expect(result._id).toBeDefined();
  347. expect(result._id.toString).toBeDefined();
  348. expect(typeof result._id.toString()).toBe('string');
  349. expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
  350. });
  351. test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async () => {
  352. // Create test child page first
  353. await Page.create({
  354. path: '/test-child',
  355. revision: new mongoose.Types.ObjectId(),
  356. creator: testUser._id,
  357. lastUpdateUser: testUser._id,
  358. grant: 1, // GRANT_PUBLIC
  359. isEmpty: false,
  360. descendantCount: 0,
  361. parent: rootPage._id,
  362. });
  363. const results =
  364. await pageListingService.findChildrenByParentPathOrIdAndViewer(
  365. '/',
  366. testUser,
  367. );
  368. expect(Array.isArray(results)).toBe(true);
  369. results.forEach((result) => {
  370. // Validate _id behavior from exec() any return type
  371. expect(result._id).toBeDefined();
  372. expect(result._id.toString).toBeDefined();
  373. expect(typeof result._id.toString()).toBe('string');
  374. expect(result._id.toString().length).toBe(24);
  375. });
  376. });
  377. });
  378. });