Просмотр исходного кода

Merge pull request #10739 from growilabs/copilot/discuss-ci-test-job

fix: Use external MongoDB in CI to enable parallel test execution after Vitest migration
Yuki Takei 2 месяцев назад
Родитель
Сommit
ab794a8ad2

+ 1 - 1
.serena/memories/project_structure.md

@@ -39,7 +39,7 @@ apps/app/
 ### test/
 - Vitest用のファイル
 - 新規テスト用のユーティリティはここに作成
-- セットアップファイル: `setup/mongoms.ts` (MongoDB用)
+- セットアップファイル: `setup/mongo.ts` (MongoDB用)
 
 ### playwright/
 - Playwright による E2E テスト用ディレクトリ

+ 1 - 0
apps/app/package.json

@@ -316,6 +316,7 @@
     "material-icons": "^1.11.3",
     "mdast-util-directive": "^3.0.0",
     "mdast-util-find-and-replace": "^3.0.1",
+    "mongodb-connection-string-url": "^7.0.0",
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",

+ 31 - 11
apps/app/src/features/page-tree/components/ItemsTree.spec.tsx

@@ -1,7 +1,7 @@
 import type React from 'react';
 import type { FC } from 'react';
 import { Suspense } from 'react';
-import { render, waitFor } from '@testing-library/react';
+import { act, render, waitFor } from '@testing-library/react';
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 
@@ -179,7 +179,9 @@ describe('ItemsTree', () => {
       });
 
       // Give time for any additional API calls that might happen
-      await new Promise((resolve) => setTimeout(resolve, 100));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 100));
+      });
 
       // Key assertion: API should only be called for:
       // 1. root-page-id (the only expanded node by default)
@@ -241,7 +243,9 @@ describe('ItemsTree', () => {
         expect(mockApiv3Get).toHaveBeenCalled();
       });
 
-      await new Promise((resolve) => setTimeout(resolve, 100));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 100));
+      });
 
       const childrenApiCalls = mockApiv3Get.mock.calls.filter(
         (call) => call[0] === '/page-listing/children',
@@ -306,7 +310,9 @@ describe('ItemsTree', () => {
       });
 
       // Wait for any potential additional API calls
-      await new Promise((resolve) => setTimeout(resolve, 200));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 200));
+      });
 
       // Critical assertion: Only root-page-id should have children fetched
       // folder-1 and folder-2 should NOT be fetched even though they are folders (descendantCount > 0)
@@ -385,7 +391,9 @@ describe('ItemsTree', () => {
       );
 
       // Give some extra time for any unwanted calls
-      await new Promise((resolve) => setTimeout(resolve, 200));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 200));
+      });
 
       // Should fetch: root-page-id (initial), sandbox-id (ancestor of target)
       // Should NOT fetch: other-id (not an ancestor of target)
@@ -469,7 +477,9 @@ describe('ItemsTree', () => {
 
       // Wait a reasonable amount of time to detect infinite loops
       // If there's an infinite loop, we'd see many API calls within this time
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // Force re-render to simulate React re-renders that could trigger the loop
       rerender(
@@ -485,7 +495,9 @@ describe('ItemsTree', () => {
       );
 
       // Wait more time for potential infinite loop to manifest
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // Key assertion: API calls for parent-1 should be bounded
       // An infinite loop would cause this count to be very high (100+)
@@ -555,7 +567,9 @@ describe('ItemsTree', () => {
       await waitFor(() => {
         expect(mockApiv3Get).toHaveBeenCalled();
       });
-      await new Promise((resolve) => setTimeout(resolve, 200));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 200));
+      });
 
       const callsAfterInitialLoad = totalApiCalls;
 
@@ -564,7 +578,9 @@ describe('ItemsTree', () => {
       // we're mainly testing the initial render with creatingParentId set
 
       // Wait to ensure no more calls happen
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // Verify API calls stabilized
       expect(totalApiCalls).toBeLessThanOrEqual(callsAfterInitialLoad + 2);
@@ -624,7 +640,9 @@ describe('ItemsTree', () => {
       await waitFor(() => {
         expect(mockApiv3Get).toHaveBeenCalled();
       });
-      await new Promise((resolve) => setTimeout(resolve, 300));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 300));
+      });
 
       const callsBeforeReset = apiCallCount;
 
@@ -647,7 +665,9 @@ describe('ItemsTree', () => {
         </TestWrapper>,
       );
 
-      await new Promise((resolve) => setTimeout(resolve, 500));
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 500));
+      });
 
       // API calls should be bounded even after state changes
       // The difference should be minimal (just the initial load after remount)

+ 7 - 3
apps/app/src/server/service/growi-info/growi-info.integ.ts

@@ -19,6 +19,8 @@ describe('GrowiInfoService', () => {
   let User: mongoose.Model<IUser>;
   let Page: PageModel;
 
+  let serviceInstanceId: string;
+
   beforeAll(async () => {
     process.env.APP_SITE_URL = 'http://growi.test.jp';
     process.env.DEPLOYMENT_TYPE = 'growi-docker-compose';
@@ -34,6 +36,8 @@ describe('GrowiInfoService', () => {
       'security:passport-github:isEnabled': true,
     });
 
+    serviceInstanceId = configManager.getConfig('app:serviceInstanceId');
+
     await Config.create({
       key: 'app:installed',
       value: true,
@@ -79,7 +83,7 @@ describe('GrowiInfoService', () => {
       expect(growiInfo).toEqual({
         version: appVersion,
         appSiteUrl: 'http://growi.test.jp',
-        serviceInstanceId: '',
+        serviceInstanceId,
         type: 'on-premise',
         wikiType: 'closed',
         deploymentType: 'growi-docker-compose',
@@ -109,7 +113,7 @@ describe('GrowiInfoService', () => {
       expect(growiInfo).toEqual({
         version: appVersion,
         appSiteUrl: 'http://growi.test.jp',
-        serviceInstanceId: '',
+        serviceInstanceId,
         type: 'on-premise',
         wikiType: 'closed',
         deploymentType: 'growi-docker-compose',
@@ -222,7 +226,7 @@ describe('GrowiInfoService', () => {
       expect(growiInfo).toEqual({
         version: appVersion,
         appSiteUrl: 'http://growi.test.jp',
-        serviceInstanceId: '',
+        serviceInstanceId,
         type: 'on-premise',
         wikiType: 'closed',
         deploymentType: 'growi-docker-compose',

+ 98 - 39
apps/app/src/states/page/use-fetch-current-page.spec.tsx

@@ -8,7 +8,7 @@ import type {
   PageGrant,
   PageStatus,
 } from '@growi/core/dist/interfaces';
-import { renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
 // biome-ignore lint/style/noRestrictedImports: import only types
 import type { AxiosResponse } from 'axios';
 import { createStore, Provider } from 'jotai';
@@ -180,7 +180,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/new/page' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/new/page' });
+    });
 
     // Assert: Wait for state updates
     await waitFor(() => {
@@ -211,7 +213,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/same/path' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/same/path' });
+    });
 
     // Assert
     // Use a short timeout to ensure no fetch is initiated
@@ -231,7 +235,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ pageId: 'page123' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ pageId: 'page123' });
+    });
 
     // Assert
     await new Promise((resolve) => setTimeout(resolve, 100));
@@ -261,9 +267,11 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({
-      path: '/same/path',
-      revisionId: 'rev_different',
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        path: '/same/path',
+        revisionId: 'rev_different',
+      });
     });
 
     // Assert
@@ -294,9 +302,11 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({
-      path: '/same/path',
-      revisionId: currentRevisionId,
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        path: '/same/path',
+        revisionId: currentRevisionId,
+      });
     });
 
     // Assert
@@ -327,9 +337,11 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({
-      path: '/same/path',
-      revisionId: 'rev_old',
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        path: '/same/path',
+        revisionId: 'rev_old',
+      });
     });
 
     // Assert
@@ -367,7 +379,12 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/same/path', force: true });
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        path: '/same/path',
+        force: true,
+      });
+    });
 
     // Assert
     await waitFor(() => {
@@ -405,7 +422,12 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ pageId: 'pageId123', force: true });
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        pageId: 'pageId123',
+        force: true,
+      });
+    });
 
     // Assert
     await waitFor(() => {
@@ -444,9 +466,11 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({
-      path: `/${permalinkId}`,
-      force: true,
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        path: `/${permalinkId}`,
+        force: true,
+      });
     });
 
     // Assert
@@ -475,7 +499,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockResolvedValue(mockApiResponse(regularPageData));
 
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/some/page' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/some/page' });
+    });
 
     await waitFor(() => {
       expect(store.get(currentPageEntityIdAtom)).toBe('regularPageId');
@@ -491,7 +517,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockResolvedValue(mockApiResponse(rootPageData));
 
     // Act
-    await result.current.fetchCurrentPage({ path: '/' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/' });
+    });
 
     // Assert: Navigation to root works
     await waitFor(() => {
@@ -516,7 +544,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: encodedPath });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: encodedPath });
+    });
 
     // Assert
     await waitFor(() => {
@@ -540,7 +570,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: permalink });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: permalink });
+    });
 
     // Assert
     await waitFor(() => {
@@ -567,7 +599,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: permalinkPath });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: permalinkPath });
+    });
 
     // Assert
     await waitFor(() => {
@@ -604,9 +638,11 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({
-      path: permalinkPath,
-      pageId: explicitPageId,
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        path: permalinkPath,
+        pageId: explicitPageId,
+      });
     });
 
     // Assert
@@ -640,7 +676,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: regularPath });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: regularPath });
+    });
 
     // Assert
     await waitFor(() => {
@@ -674,7 +712,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: permalinkWithHash });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: permalinkWithHash });
+    });
 
     // Assert
     await waitFor(() => {
@@ -733,7 +773,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
       // Act
       const { result } = renderHookWithProvider();
-      await result.current.fetchCurrentPage({ path: testCase.input });
+      await act(async () => {
+        await result.current.fetchCurrentPage({ path: testCase.input });
+      });
 
       // Assert
       await waitFor(() => {
@@ -769,7 +811,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockRejectedValueOnce([notFoundError]);
 
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/will/not/found' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/will/not/found' });
+    });
 
     // Assert
     await waitFor(() => {
@@ -794,7 +838,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockRejectedValueOnce(unknownError);
 
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/any/path' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/any/path' });
+    });
 
     await waitFor(() => {
       const err = store.get(pageErrorAtom);
@@ -813,7 +859,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockRejectedValueOnce([]);
 
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/any/path' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/any/path' });
+    });
 
     await waitFor(() => {
       const err = store.get(pageErrorAtom);
@@ -836,7 +884,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockRejectedValueOnce([nonErrorV3]);
 
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/any/path' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/any/path' });
+    });
 
     await waitFor(() => {
       expect(store.get(pageLoadingAtom)).toBe(false);
@@ -870,7 +920,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockResolvedValueOnce(notFoundResponseWithEmptyPage);
 
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/empty/page' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/empty/page' });
+    });
 
     // Assert: emptyPageId should be set from meta
     await waitFor(() => {
@@ -902,7 +954,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
     mockedApiv3Get.mockResolvedValueOnce(notFoundResponseWithoutEmptyPage);
 
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/regular/not/found' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/regular/not/found' });
+    });
 
     // Assert: emptyPageId should be undefined
     await waitFor(() => {
@@ -930,7 +984,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/success/path' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/success/path' });
+    });
 
     // Assert: emptyPageId should be reset to undefined
     await waitFor(() => {
@@ -945,7 +1001,6 @@ describe('useFetchCurrentPage - Integration Test', () => {
   it('should handle path with encoded Japanese characters', async () => {
     // Arrange: Path with Japanese characters
     const japanesePath = '/日本語/ページ';
-    const encodedPath = encodeURIComponent(japanesePath);
     const pageData = createPageDataMock(
       'japanesePageId',
       japanesePath,
@@ -955,7 +1010,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: japanesePath });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: japanesePath });
+    });
 
     // Assert: Path should be properly decoded and sent to API
     await waitFor(() => {
@@ -978,7 +1035,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
     // Act
     const { result } = renderHookWithProvider();
-    await result.current.fetchCurrentPage({ path: '/test/path' });
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/test/path' });
+    });
 
     // Assert: mutatePageInfo should be called to refetch metadata
     await waitFor(() => {

+ 43 - 0
apps/app/test/setup/migrate-mongo.ts

@@ -0,0 +1,43 @@
+import { execSync } from 'node:child_process';
+import { beforeAll } from 'vitest';
+
+import { getTestDbConfig } from './mongo';
+
+// Track if migrations have been run for this worker
+let migrationsRun = false;
+
+/**
+ * Run database migrations using external process.
+ * This uses the existing dev:migrate:up script which has ts-node and tsconfig-paths configured.
+ */
+function runMigrations(mongoUri: string): void {
+  // Run migrations using the existing script with custom MONGO_URI
+  execSync('pnpm run dev:migrate:up', {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      MONGO_URI: mongoUri,
+    },
+    stdio: 'inherit',
+  });
+}
+
+beforeAll(() => {
+  // Skip if already run (setupFiles run per test file, but we only need to migrate once per worker)
+  if (migrationsRun) {
+    return;
+  }
+
+  const { dbName, mongoUri } = getTestDbConfig();
+
+  // Only run migrations when using external MongoDB (CI environment)
+  if (mongoUri == null) {
+    return;
+  }
+
+  // biome-ignore lint/suspicious/noConsole: Allow logging
+  console.log(`Running migrations for ${dbName}...`);
+
+  runMigrations(mongoUri);
+  migrationsRun = true;
+});

+ 150 - 0
apps/app/test/setup/mongo.spec.ts

@@ -0,0 +1,150 @@
+import { describe, expect, it } from 'vitest';
+
+import { replaceMongoDbName } from './mongo';
+
+describe('replaceMongoDbName', () => {
+  describe('single-host URIs', () => {
+    it('should replace database name in basic URI', () => {
+      const result = replaceMongoDbName(
+        'mongodb://localhost:27017/growi_test',
+        'new_db',
+      );
+      expect(result).toBe('mongodb://localhost:27017/new_db');
+    });
+
+    it('should add database name when URI has no database', () => {
+      const result = replaceMongoDbName('mongodb://localhost:27017', 'new_db');
+      expect(result).toBe('mongodb://localhost:27017/new_db');
+    });
+
+    it('should add database name when URI ends with slash', () => {
+      const result = replaceMongoDbName('mongodb://localhost:27017/', 'new_db');
+      expect(result).toBe('mongodb://localhost:27017/new_db');
+    });
+
+    it('should preserve query parameters', () => {
+      const result = replaceMongoDbName(
+        'mongodb://localhost:27017?param=value',
+        'new_db',
+      );
+      expect(result).toBe('mongodb://localhost:27017/new_db?param=value');
+    });
+
+    it('should replace database name and preserve query parameters', () => {
+      const result = replaceMongoDbName(
+        'mongodb://localhost:27017/growi_test?param=value',
+        'new_db',
+      );
+      expect(result).toBe('mongodb://localhost:27017/new_db?param=value');
+    });
+
+    it('should handle authentication credentials', () => {
+      const result = replaceMongoDbName(
+        'mongodb://user:pass@localhost:27017/growi_test',
+        'new_db',
+      );
+      expect(result).toBe('mongodb://user:pass@localhost:27017/new_db');
+    });
+
+    it('should handle authentication credentials with query parameters', () => {
+      const result = replaceMongoDbName(
+        'mongodb://user:pass@localhost:27017/growi_test?authSource=admin',
+        'new_db',
+      );
+      expect(result).toBe(
+        'mongodb://user:pass@localhost:27017/new_db?authSource=admin',
+      );
+    });
+
+    it('should handle URL-encoded credentials', () => {
+      const result = replaceMongoDbName(
+        'mongodb://user%40name:p%40ss@localhost:27017/growi_test',
+        'new_db',
+      );
+      expect(result).toBe(
+        'mongodb://user%40name:p%40ss@localhost:27017/new_db',
+      );
+    });
+  });
+
+  describe('replica set URIs (multiple hosts)', () => {
+    it('should replace database name in replica set URI', () => {
+      const result = replaceMongoDbName(
+        'mongodb://host1:27017,host2:27017/growi_test?replicaSet=rs0',
+        'new_db',
+      );
+      expect(result).toBe(
+        'mongodb://host1:27017,host2:27017/new_db?replicaSet=rs0',
+      );
+    });
+
+    it('should add database name to replica set URI without database', () => {
+      const result = replaceMongoDbName(
+        'mongodb://host1:27017,host2:27017,host3:27017?replicaSet=rs0',
+        'new_db',
+      );
+      expect(result).toBe(
+        'mongodb://host1:27017,host2:27017,host3:27017/new_db?replicaSet=rs0',
+      );
+    });
+
+    it('should handle replica set URI with authentication', () => {
+      const result = replaceMongoDbName(
+        'mongodb://user:pass@host1:27017,host2:27017/growi_test?replicaSet=rs0',
+        'new_db',
+      );
+      expect(result).toBe(
+        'mongodb://user:pass@host1:27017,host2:27017/new_db?replicaSet=rs0',
+      );
+    });
+
+    it('should handle replica set URI without query parameters', () => {
+      const result = replaceMongoDbName(
+        'mongodb://host1:27017,host2:27017/growi_test',
+        'new_db',
+      );
+      expect(result).toBe('mongodb://host1:27017,host2:27017/new_db');
+    });
+  });
+
+  describe('edge cases', () => {
+    it('should handle different database names', () => {
+      const result = replaceMongoDbName(
+        'mongodb://localhost:27017/growi_test',
+        'growi_test_1',
+      );
+      expect(result).toBe('mongodb://localhost:27017/growi_test_1');
+    });
+
+    it('should handle database names with underscores and numbers', () => {
+      const result = replaceMongoDbName(
+        'mongodb://localhost:27017/old_db_123',
+        'new_db_456',
+      );
+      expect(result).toBe('mongodb://localhost:27017/new_db_456');
+    });
+
+    it('should preserve all query parameters', () => {
+      const result = replaceMongoDbName(
+        'mongodb://localhost:27017/growi_test?authSource=admin&retryWrites=true&w=majority',
+        'new_db',
+      );
+      expect(result).toBe(
+        'mongodb://localhost:27017/new_db?authSource=admin&retryWrites=true&w=majority',
+      );
+    });
+  });
+
+  describe('error handling', () => {
+    it('should throw error for invalid URI protocol', () => {
+      // mongodb-connection-string-url validates protocol
+      expect(() =>
+        replaceMongoDbName('http://localhost:27017/db', 'new_db'),
+      ).toThrow();
+    });
+
+    it('should throw error for malformed URI', () => {
+      expect(() => replaceMongoDbName('not-a-uri', 'new_db')).toThrow();
+    });
+  });
+});

+ 92 - 0
apps/app/test/setup/mongo.ts

@@ -0,0 +1,92 @@
+import ConnectionString from 'mongodb-connection-string-url';
+import { MongoMemoryServer } from 'mongodb-memory-server-core';
+import mongoose from 'mongoose';
+import { afterAll, beforeAll } from 'vitest';
+
+import { mongoOptions } from '~/server/util/mongoose-utils';
+
+let mongoServer: MongoMemoryServer | undefined;
+
+/**
+ * Replace the database name in a MongoDB connection URI.
+ * Uses mongodb-connection-string-url package for robust parsing.
+ * Supports various URI formats including authentication, replica sets, and query parameters.
+ *
+ * @param uri - MongoDB connection URI
+ * @param newDbName - New database name to use
+ * @returns Modified URI with the new database name
+ */
+export function replaceMongoDbName(uri: string, newDbName: string): string {
+  const cs = new ConnectionString(uri);
+  cs.pathname = `/${newDbName}`;
+  return cs.href;
+}
+
+/**
+ * Get test database configuration for the current Vitest worker.
+ * Each worker gets a unique database name to avoid conflicts in parallel execution.
+ */
+export function getTestDbConfig(): {
+  workerId: string;
+  dbName: string;
+  mongoUri: string | null;
+} {
+  // VITEST_WORKER_ID is provided by Vitest (e.g., "1", "2", "3"...)
+  const workerId = process.env.VITEST_WORKER_ID || '1';
+  const dbName = `growi_test_${workerId}`;
+  const mongoUri = process.env.MONGO_URI
+    ? replaceMongoDbName(process.env.MONGO_URI, dbName)
+    : null;
+
+  return { workerId, dbName, mongoUri };
+}
+
+beforeAll(async () => {
+  // Skip if already connected (setupFiles run per test file, but connection persists per worker)
+  if (mongoose.connection.readyState === 1) {
+    return;
+  }
+
+  const { workerId, dbName, mongoUri } = getTestDbConfig();
+
+  // Use external MongoDB if MONGO_URI is provided (e.g., in CI with GitHub Actions services)
+  if (mongoUri != null) {
+    // biome-ignore lint/suspicious/noConsole: Allow logging
+    console.log(`Using external MongoDB at ${mongoUri} (worker: ${workerId})`);
+
+    // Migrations are run by migrate-mongo.ts setup file
+    await mongoose.connect(mongoUri, mongoOptions);
+    return;
+  }
+
+  // Use MongoMemoryServer for local development
+  // set debug flag
+  process.env.MONGOMS_DEBUG = process.env.VITE_MONGOMS_DEBUG;
+
+  // set version
+  mongoServer = await MongoMemoryServer.create({
+    instance: {
+      dbName,
+    },
+    binary: {
+      version: process.env.VITE_MONGOMS_VERSION,
+      downloadDir: 'node_modules/.cache/mongodb-binaries',
+    },
+  });
+
+  // biome-ignore lint/suspicious/noConsole: Allow logging
+  console.log(
+    `MongoMemoryServer is running on ${mongoServer.getUri()} (worker: ${workerId})`,
+  );
+
+  await mongoose.connect(mongoServer.getUri(), mongoOptions);
+});
+
+afterAll(async () => {
+  await mongoose.disconnect();
+
+  // Stop MongoMemoryServer if it was created
+  if (mongoServer) {
+    await mongoServer.stop();
+  }
+});

+ 0 - 29
apps/app/test/setup/mongoms.ts

@@ -1,29 +0,0 @@
-import { MongoMemoryServer } from 'mongodb-memory-server-core';
-import mongoose from 'mongoose';
-
-import { mongoOptions } from '~/server/util/mongoose-utils';
-
-beforeAll(async () => {
-  // set debug flag
-  process.env.MONGOMS_DEBUG = process.env.VITE_MONGOMS_DEBUG;
-
-  // set version
-  const mongoServer = await MongoMemoryServer.create({
-    instance: {
-      dbName: 'growi_test',
-    },
-    binary: {
-      version: process.env.VITE_MONGOMS_VERSION,
-      downloadDir: 'node_modules/.cache/mongodb-binaries',
-    },
-  });
-
-  // biome-ignore lint/suspicious/noConsole: Allow logging
-  console.log(`MongoMemoryServer is running on ${mongoServer.getUri()}`);
-
-  await mongoose.connect(mongoServer.getUri(), mongoOptions);
-});
-
-afterAll(async () => {
-  await mongoose.disconnect();
-});

+ 2 - 2
apps/app/vitest.workspace.mts

@@ -7,7 +7,7 @@ const configShared = defineConfig({
   test: {
     clearMocks: true,
     globals: true,
-    exclude: ['test/**', 'playwright/**'],
+    exclude: ['playwright/**'],
   },
 });
 
@@ -31,7 +31,7 @@ export default defineWorkspace([
       name: 'app-integration',
       environment: 'node',
       include: ['**/*.integ.ts'],
-      setupFiles: ['./test/setup/mongoms.ts'],
+      setupFiles: ['./test/setup/migrate-mongo.ts', './test/setup/mongo.ts'],
       deps: {
         // Transform inline modules (allows ESM in require context)
         interopDefault: true,

+ 19 - 0
pnpm-lock.yaml

@@ -911,6 +911,9 @@ importers:
       mdast-util-find-and-replace:
         specifier: ^3.0.1
         version: 3.0.1
+      mongodb-connection-string-url:
+        specifier: ^7.0.0
+        version: 7.0.0
       mongodb-memory-server-core:
         specifier: ^9.1.1
         version: 9.4.0(@aws-sdk/credential-providers@3.600.0(@aws-sdk/client-sso-oidc@3.600.0))
@@ -5604,6 +5607,9 @@ packages:
   '@types/whatwg-url@11.0.5':
     resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
 
+  '@types/whatwg-url@13.0.0':
+    resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
+
   '@types/whatwg-url@8.2.1':
     resolution: {integrity: sha512-2YubE1sjj5ifxievI5Ge1sckb9k/Er66HyR2c+3+I6VDUUg1TLPdYYTEbQ+DjRkS4nTxMJhgWfSfMRD2sl2EYQ==}
 
@@ -10645,6 +10651,10 @@ packages:
   mongodb-connection-string-url@3.0.1:
     resolution: {integrity: sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==}
 
+  mongodb-connection-string-url@7.0.0:
+    resolution: {integrity: sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==}
+    engines: {node: '>=20.19.0'}
+
   mongodb-memory-server-core@9.4.0:
     resolution: {integrity: sha512-irqdj/RPHJ2M9lgtxrvhXUbqFv/DfmUG+wvcAqtgtBJ8qVq1VGBD5rkKkLP5b3g8OoadP3OzsXTGCi1P2dDBQQ==}
     engines: {node: '>=14.20.1'}
@@ -20265,6 +20275,10 @@ snapshots:
     dependencies:
       '@types/webidl-conversions': 6.1.1
 
+  '@types/whatwg-url@13.0.0':
+    dependencies:
+      '@types/webidl-conversions': 6.1.1
+
   '@types/whatwg-url@8.2.1':
     dependencies:
       '@types/node': 20.19.17
@@ -25907,6 +25921,11 @@ snapshots:
       '@types/whatwg-url': 11.0.5
       whatwg-url: 13.0.0
 
+  mongodb-connection-string-url@7.0.0:
+    dependencies:
+      '@types/whatwg-url': 13.0.0
+      whatwg-url: 14.2.0
+
   mongodb-memory-server-core@9.4.0(@aws-sdk/credential-providers@3.600.0(@aws-sdk/client-sso-oidc@3.600.0)):
     dependencies:
       async-mutex: 0.4.1