Selaa lähdekoodia

test(page-tree): add integration tests for use-data-loader with real Jotai atoms

These tests verify that the dataLoader correctly reads creating state
from Jotai atoms without using mocks. This catches bugs like using
getDefaultStore() incorrectly, which would fail when the test creates
its own store via createStore().
Yuki Takei 4 kuukautta sitten
vanhempi
sitoutus
5ccc843436

+ 320 - 0
apps/app/src/features/page-tree/client/hooks/use-data-loader.integration.spec.tsx

@@ -0,0 +1,320 @@
+/**
+ * Integration tests for use-data-loader with real Jotai atoms
+ *
+ * These tests verify that the dataLoader correctly reads creating state
+ * from Jotai atoms. This is critical because:
+ *
+ * 1. The dataLoader callbacks (getChildrenWithData) need to read the latest
+ *    creating state when they are invoked
+ * 2. The dataLoader reference must remain stable to prevent headless-tree
+ *    from refetching all data
+ * 3. Changes to the creating state must be reflected in subsequent
+ *    getChildrenWithData calls WITHOUT recreating the dataLoader
+ *
+ * These tests use real Jotai atoms instead of mocks to ensure the integration
+ * works correctly. This catches bugs like using getDefaultStore() incorrectly.
+ */
+import type { FC, PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+// Re-import the actions hook to use real implementation
+import {
+  CREATING_PAGE_VIRTUAL_ID,
+  usePageTreeCreateActions,
+} from '../states/page-tree-create';
+import { clearChildrenCache, useDataLoader } from './use-data-loader';
+
+/**
+ * Type helper to extract getChildrenWithData from TreeDataLoader
+ */
+type DataLoaderWithChildrenData = ReturnType<typeof useDataLoader> & {
+  getChildrenWithData: (
+    itemId: string,
+  ) => Promise<{ id: string; data: IPageForTreeItem }[]>;
+};
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+/**
+ * Create mock page data for testing
+ */
+const createMockPage = (
+  id: string,
+  path: string,
+  options: Partial<IPageForTreeItem> = {},
+): IPageForTreeItem => ({
+  _id: id,
+  path,
+  parent: null,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+  ...options,
+});
+
+/**
+ * Helper to get typed dataLoader with getChildrenWithData
+ */
+const getDataLoader = (result: {
+  current: ReturnType<typeof useDataLoader>;
+}): DataLoaderWithChildrenData => {
+  return result.current as DataLoaderWithChildrenData;
+};
+
+describe('use-data-loader integration with Jotai atoms', () => {
+  const ROOT_PAGE_ID = 'root-page-id';
+  const ALL_PAGES_COUNT = 100;
+
+  // Create a fresh Jotai store for each test
+  let store: ReturnType<typeof createStore>;
+
+  // Wrapper component that provides the Jotai store
+  const createWrapper = (): FC<PropsWithChildren> => {
+    const Wrapper: FC<PropsWithChildren> = ({ children }) => (
+      <Provider store={store}>{children}</Provider>
+    );
+    return Wrapper;
+  };
+
+  beforeEach(() => {
+    // Create fresh store for each test
+    store = createStore();
+    // Clear cache before each test
+    clearChildrenCache();
+    // Reset mock
+    mockApiv3Get.mockReset();
+  });
+
+  describe('placeholder node with real Jotai atoms', () => {
+    test('should prepend placeholder when creating state is set via actions hook', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks in the same wrapper to share the store
+      const { result: dataLoaderResult } = renderHook(
+        () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        { wrapper },
+      );
+
+      const { result: actionsResult } = renderHook(
+        () => usePageTreeCreateActions(),
+        { wrapper },
+      );
+
+      // First call - no placeholder (creating state is null)
+      const childrenBefore =
+        await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id');
+      expect(childrenBefore).toHaveLength(1);
+      expect(childrenBefore[0].id).toBe('existing-child');
+
+      // Set creating state using the actions hook
+      act(() => {
+        actionsResult.current.startCreating('parent-id', '/parent');
+      });
+
+      // Clear cache to force re-fetch
+      clearChildrenCache(['parent-id']);
+
+      // Second call - should have placeholder because atom state changed
+      const childrenAfter =
+        await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id');
+      expect(childrenAfter).toHaveLength(2);
+      expect(childrenAfter[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(childrenAfter[0].data.parent).toBe('parent-id');
+      expect(childrenAfter[0].data.path).toBe('/parent/');
+      expect(childrenAfter[1].id).toBe('existing-child');
+    });
+
+    test('should remove placeholder when creating state is cancelled', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      const { result: dataLoaderResult } = renderHook(
+        () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        { wrapper },
+      );
+
+      const { result: actionsResult } = renderHook(
+        () => usePageTreeCreateActions(),
+        { wrapper },
+      );
+
+      // Set creating state
+      act(() => {
+        actionsResult.current.startCreating('parent-id', '/parent');
+      });
+
+      // Clear cache and fetch - should have placeholder
+      clearChildrenCache(['parent-id']);
+      const childrenWithPlaceholder =
+        await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id');
+      expect(childrenWithPlaceholder).toHaveLength(2);
+      expect(childrenWithPlaceholder[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+
+      // Cancel creating
+      act(() => {
+        actionsResult.current.cancelCreating();
+      });
+
+      // Clear cache and fetch - should NOT have placeholder
+      clearChildrenCache(['parent-id']);
+      const childrenAfterCancel =
+        await getDataLoader(dataLoaderResult).getChildrenWithData('parent-id');
+      expect(childrenAfterCancel).toHaveLength(1);
+      expect(childrenAfterCancel[0].id).toBe('existing-child');
+    });
+
+    test('dataLoader reference should remain stable when creating state changes via atom', async () => {
+      const wrapper = createWrapper();
+
+      const { result: dataLoaderResult } = renderHook(
+        () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        { wrapper },
+      );
+
+      const { result: actionsResult } = renderHook(
+        () => usePageTreeCreateActions(),
+        { wrapper },
+      );
+
+      const firstDataLoader = dataLoaderResult.current;
+
+      // Change creating state via atom
+      act(() => {
+        actionsResult.current.startCreating('some-parent', '/some-parent');
+      });
+
+      const secondDataLoader = dataLoaderResult.current;
+
+      // DataLoader reference should be STABLE (same reference)
+      // This is critical to prevent headless-tree from refetching all data
+      expect(firstDataLoader).toBe(secondDataLoader);
+    });
+
+    test('should correctly read state changes without rerender', async () => {
+      // This test verifies that the dataLoader callbacks can read the latest
+      // atom state even without a React rerender. This is the critical behavior
+      // that was broken when using getDefaultStore() incorrectly.
+
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      const { result: dataLoaderResult } = renderHook(
+        () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        { wrapper },
+      );
+
+      const { result: actionsResult } = renderHook(
+        () => usePageTreeCreateActions(),
+        { wrapper },
+      );
+
+      // Get the dataLoader reference BEFORE state change
+      const dataLoader = getDataLoader(dataLoaderResult);
+
+      // Set creating state
+      act(() => {
+        actionsResult.current.startCreating('parent-id', '/parent');
+      });
+
+      // Clear cache
+      clearChildrenCache(['parent-id']);
+
+      // Call getChildrenWithData using the SAME dataLoader reference
+      // This should still see the updated atom state
+      const children = await dataLoader.getChildrenWithData('parent-id');
+
+      expect(children).toHaveLength(2);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[1].id).toBe('existing-child');
+    });
+
+    test('should work with multiple sequential state changes', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      const { result: dataLoaderResult } = renderHook(
+        () => useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        { wrapper },
+      );
+
+      const { result: actionsResult } = renderHook(
+        () => usePageTreeCreateActions(),
+        { wrapper },
+      );
+
+      const dataLoader = getDataLoader(dataLoaderResult);
+
+      // Sequence: start -> cancel -> start again -> cancel
+      // Each time, the dataLoader should correctly reflect the state
+
+      // 1. Start creating
+      act(() => {
+        actionsResult.current.startCreating('parent-id', '/parent');
+      });
+      clearChildrenCache(['parent-id']);
+      let children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(2);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+
+      // 2. Cancel
+      act(() => {
+        actionsResult.current.cancelCreating();
+      });
+      clearChildrenCache(['parent-id']);
+      children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe('existing-child');
+
+      // 3. Start again with different parent
+      act(() => {
+        actionsResult.current.startCreating('other-parent', '/other');
+      });
+      clearChildrenCache(['parent-id', 'other-parent']);
+
+      // Original parent should NOT have placeholder
+      children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(1);
+
+      // New parent should have placeholder
+      mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
+      children = await dataLoader.getChildrenWithData('other-parent');
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data.path).toBe('/other/');
+
+      // 4. Cancel again
+      act(() => {
+        actionsResult.current.cancelCreating();
+      });
+      clearChildrenCache(['other-parent']);
+      mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
+      children = await dataLoader.getChildrenWithData('other-parent');
+      expect(children).toHaveLength(0);
+    });
+  });
+});