migrate-command.e2e-spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. /**
  2. * E2E tests for the migrate command
  3. *
  4. * To run these tests:
  5. * npm run vitest -- --config e2e/vitest.e2e.config.mts
  6. */
  7. import * as fs from 'fs-extra';
  8. import * as path from 'path';
  9. import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
  10. import {
  11. generateMigrationOperation,
  12. revertMigrationOperation,
  13. runMigrationsOperation,
  14. } from '../src/commands/migrate/migration-operations';
  15. const TEST_PROJECT_DIR = path.join(__dirname, 'fixtures', 'test-project');
  16. const MIGRATIONS_DIR = path.join(TEST_PROJECT_DIR, 'migrations');
  17. describe(
  18. 'Migrate Command E2E',
  19. () => {
  20. let originalCwd: string;
  21. beforeAll(() => {
  22. // Save the original working directory
  23. originalCwd = process.cwd();
  24. });
  25. beforeEach(async () => {
  26. // Clean up migrations directory before each test
  27. await fs.emptyDir(MIGRATIONS_DIR);
  28. // Clean up test database
  29. const dbPath = path.join(TEST_PROJECT_DIR, 'test.db');
  30. if (await fs.pathExists(dbPath)) {
  31. await fs.remove(dbPath);
  32. }
  33. });
  34. afterAll(async () => {
  35. // Restore original working directory
  36. process.chdir(originalCwd);
  37. // Clean up after tests
  38. await fs.emptyDir(MIGRATIONS_DIR);
  39. const dbPath = path.join(TEST_PROJECT_DIR, 'test.db');
  40. if (await fs.pathExists(dbPath)) {
  41. await fs.remove(dbPath);
  42. }
  43. });
  44. describe('generateMigrationOperation', () => {
  45. it('should fail when not in a Vendure project directory', async () => {
  46. // Run from a non-Vendure directory
  47. process.chdir(__dirname);
  48. const result = await generateMigrationOperation({ name: 'test-migration' });
  49. expect(result.success).toBe(false);
  50. expect(result.message).toContain('Not in a Vendure project directory');
  51. expect(result.migrationName).toBeUndefined();
  52. });
  53. it('should generate a migration when in a valid Vendure project', async () => {
  54. // Change to test project directory
  55. process.chdir(TEST_PROJECT_DIR);
  56. const result = await generateMigrationOperation({
  57. name: 'AddTestEntity',
  58. outputDir: MIGRATIONS_DIR,
  59. });
  60. expect(result.success).toBe(true);
  61. expect(result.migrationName).toBeDefined();
  62. expect(result.message).toContain('New migration generated');
  63. // Verify migration file was created
  64. const files = await fs.readdir(MIGRATIONS_DIR);
  65. const migrationFile = files.find(f => f.includes('AddTestEntity'));
  66. expect(migrationFile).toBeDefined();
  67. });
  68. it('should handle invalid migration names correctly', async () => {
  69. process.chdir(TEST_PROJECT_DIR);
  70. const invalidNames = [
  71. '123-invalid', // starts with number
  72. 'test migration', // contains space
  73. 'test@migration', // special character
  74. ];
  75. for (const name of invalidNames) {
  76. const result = await generateMigrationOperation({ name });
  77. expect(result.success).toBe(false);
  78. expect(result.message).toContain(
  79. 'must contain only letters, numbers, underscores and dashes',
  80. );
  81. expect(result.migrationName).toBeUndefined();
  82. }
  83. });
  84. it('should accept valid migration names', async () => {
  85. process.chdir(TEST_PROJECT_DIR);
  86. const validNames = [
  87. 'TestMigration',
  88. 'test-migration',
  89. 'test_migration',
  90. 'Migration123',
  91. 'ab', // minimum 2 characters
  92. ];
  93. for (const name of validNames) {
  94. const result = await generateMigrationOperation({
  95. name,
  96. outputDir: MIGRATIONS_DIR,
  97. });
  98. // Since synchronize is false, generateMigration will create the initial migration
  99. // The first time it runs, it will generate all tables
  100. // Subsequent runs may report no changes
  101. // Both are valid outcomes
  102. expect(result.success).toBe(true);
  103. expect(result.message).toBeDefined();
  104. // Clean up the generated migration file for next iteration
  105. const files = await fs.readdir(MIGRATIONS_DIR);
  106. for (const file of files) {
  107. if (file.includes(name)) {
  108. await fs.remove(path.join(MIGRATIONS_DIR, file));
  109. }
  110. }
  111. }
  112. });
  113. it('should handle missing name parameter', async () => {
  114. process.chdir(TEST_PROJECT_DIR);
  115. const result = await generateMigrationOperation({});
  116. expect(result.success).toBe(false);
  117. expect(result.message).toContain('Migration name is required');
  118. expect(result.migrationName).toBeUndefined();
  119. });
  120. it('should use custom output directory when specified', async () => {
  121. process.chdir(TEST_PROJECT_DIR);
  122. const customDir = path.join(TEST_PROJECT_DIR, 'custom-migrations');
  123. await fs.ensureDir(customDir);
  124. try {
  125. const result = await generateMigrationOperation({
  126. name: 'CustomDirTest',
  127. outputDir: customDir,
  128. });
  129. expect(result.success).toBe(true);
  130. // Verify migration was created in custom directory
  131. const files = await fs.readdir(customDir);
  132. const migrationFile = files.find(f => f.includes('CustomDirTest'));
  133. expect(migrationFile).toBeDefined();
  134. } finally {
  135. await fs.remove(customDir);
  136. }
  137. });
  138. it('should handle TypeORM config errors gracefully', async () => {
  139. process.chdir(TEST_PROJECT_DIR);
  140. // Temporarily rename the vendure config to simulate a missing config
  141. const configPath = path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.ts');
  142. const backupPath = path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.backup.ts');
  143. await fs.move(configPath, backupPath);
  144. try {
  145. const result = await generateMigrationOperation({ name: 'FailTest' });
  146. expect(result.success).toBe(false);
  147. expect(result.message).toBeDefined();
  148. expect(result.migrationName).toBeUndefined();
  149. } finally {
  150. await fs.move(backupPath, configPath);
  151. }
  152. });
  153. });
  154. describe('runMigrationsOperation', () => {
  155. it('should fail when not in a Vendure project directory', async () => {
  156. process.chdir(__dirname);
  157. const result = await runMigrationsOperation();
  158. expect(result.success).toBe(false);
  159. expect(result.message).toContain('Not in a Vendure project directory');
  160. expect(result.migrationsRan).toBeUndefined();
  161. });
  162. it('should report no pending migrations when none exist', async () => {
  163. process.chdir(TEST_PROJECT_DIR);
  164. const result = await runMigrationsOperation();
  165. expect(result.success).toBe(true);
  166. expect(result.message).toContain('No pending migrations found');
  167. expect(result.migrationsRan).toBeDefined();
  168. expect(result.migrationsRan).toHaveLength(0);
  169. });
  170. it('should run pending migrations successfully', async () => {
  171. process.chdir(TEST_PROJECT_DIR);
  172. // First generate a migration
  173. const generateResult = await generateMigrationOperation({
  174. name: 'TestMigration',
  175. outputDir: MIGRATIONS_DIR,
  176. });
  177. expect(generateResult.success).toBe(true);
  178. // Then run migrations
  179. const runResult = await runMigrationsOperation();
  180. expect(runResult.success).toBe(true);
  181. expect(runResult.message).toContain('Successfully ran');
  182. expect(runResult.migrationsRan).toBeDefined();
  183. expect(runResult.migrationsRan?.length).toBeGreaterThan(0);
  184. });
  185. it('should handle database connection errors gracefully', async () => {
  186. process.chdir(TEST_PROJECT_DIR);
  187. // Ensure a clean module state before mocking
  188. vi.resetModules();
  189. // Mock the loadVendureConfigFile helper to return a config with an invalid database path
  190. vi.doMock('../src/shared/load-vendure-config-file', async () => {
  191. const { config: realConfig }: { config: any } = await vi.importActual(
  192. path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.ts'),
  193. );
  194. return {
  195. __esModule: true,
  196. loadVendureConfigFile: () =>
  197. Promise.resolve({
  198. ...realConfig,
  199. dbConnectionOptions: {
  200. ...realConfig.dbConnectionOptions,
  201. database: '/nonexistent/dir/test.db',
  202. },
  203. }),
  204. };
  205. });
  206. // Re-import the operation after the mock so that it picks up the mocked helper
  207. const { runMigrationsOperation: runMigrationsWithInvalidDb } = await import(
  208. '../src/commands/migrate/migration-operations'
  209. );
  210. const result = await runMigrationsWithInvalidDb();
  211. expect(result.success).toBe(false);
  212. expect(result.message).toBeDefined();
  213. expect(result.migrationsRan).toBeUndefined();
  214. // Clean up mock for subsequent tests
  215. vi.unmock('../src/commands/migrate/load-vendure-config-file');
  216. });
  217. });
  218. describe('revertMigrationOperation', () => {
  219. it('should fail when not in a Vendure project directory', async () => {
  220. process.chdir(__dirname);
  221. const result = await revertMigrationOperation();
  222. expect(result.success).toBe(false);
  223. expect(result.message).toContain('Not in a Vendure project directory');
  224. });
  225. it('should revert the last migration successfully', async () => {
  226. process.chdir(TEST_PROJECT_DIR);
  227. // Generate and run a migration first
  228. const generateResult = await generateMigrationOperation({
  229. name: 'RevertTest',
  230. outputDir: MIGRATIONS_DIR,
  231. });
  232. expect(generateResult.success).toBe(true);
  233. const runResult = await runMigrationsOperation();
  234. expect(runResult.success).toBe(true);
  235. // Now revert
  236. const revertResult = await revertMigrationOperation();
  237. expect(revertResult.success).toBe(true);
  238. expect(revertResult.message).toBe('Successfully reverted last migration');
  239. });
  240. it('should handle no migrations to revert gracefully', async () => {
  241. process.chdir(TEST_PROJECT_DIR);
  242. // Try to revert when no migrations have been run
  243. const result = await revertMigrationOperation();
  244. // This might fail or succeed depending on TypeORM behavior
  245. // The important thing is it doesn't throw and returns a structured result
  246. expect(result).toHaveProperty('success');
  247. expect(result).toHaveProperty('message');
  248. });
  249. });
  250. describe('Integration scenarios', () => {
  251. it('should handle a complete migration workflow', async () => {
  252. process.chdir(TEST_PROJECT_DIR);
  253. // 1. Generate first migration
  254. const generate1 = await generateMigrationOperation({
  255. name: 'InitialSchema',
  256. outputDir: MIGRATIONS_DIR,
  257. });
  258. expect(generate1.success).toBe(true);
  259. // 2. Run migrations
  260. const run1 = await runMigrationsOperation();
  261. expect(run1.success).toBe(true);
  262. // Since there are no actual schema changes, migrations might be empty
  263. // This is expected behavior
  264. // 3. Generate second migration
  265. const generate2 = await generateMigrationOperation({
  266. name: 'AddColumns',
  267. outputDir: MIGRATIONS_DIR,
  268. });
  269. expect(generate2.success).toBe(true);
  270. // 4. Try to run migrations again
  271. const run2 = await runMigrationsOperation();
  272. expect(run2.success).toBe(true);
  273. // Since no actual schema changes, this might have 0 migrations
  274. // which is acceptable
  275. });
  276. it('should handle concurrent operations gracefully', async () => {
  277. process.chdir(TEST_PROJECT_DIR);
  278. // Try to run multiple operations concurrently
  279. const operations = [
  280. generateMigrationOperation({ name: 'Concurrent1', outputDir: MIGRATIONS_DIR }),
  281. generateMigrationOperation({ name: 'Concurrent2', outputDir: MIGRATIONS_DIR }),
  282. generateMigrationOperation({ name: 'Concurrent3', outputDir: MIGRATIONS_DIR }),
  283. ];
  284. const results = await Promise.all(operations);
  285. // All should complete without throwing
  286. results.forEach(result => {
  287. expect(result).toHaveProperty('success');
  288. expect(result).toHaveProperty('message');
  289. });
  290. // At least some should succeed
  291. const successCount = results.filter(r => r.success).length;
  292. expect(successCount).toBeGreaterThan(0);
  293. });
  294. });
  295. describe('Error recovery', () => {
  296. it('should recover from interrupted migration generation', async () => {
  297. process.chdir(TEST_PROJECT_DIR);
  298. // Create a partial migration file to simulate interruption
  299. const partialFile = path.join(MIGRATIONS_DIR, '1234567890-Partial.ts');
  300. await fs.writeFile(partialFile, 'export class Partial1234567890 {}');
  301. // Should still be able to generate new migrations
  302. const result = await generateMigrationOperation({
  303. name: 'RecoveryTest',
  304. outputDir: MIGRATIONS_DIR,
  305. });
  306. // The operation should complete successfully
  307. expect(result.success).toBe(true);
  308. expect(result.message).toBeDefined();
  309. });
  310. it('should provide helpful error messages for common issues', async () => {
  311. process.chdir(TEST_PROJECT_DIR);
  312. // Test various error scenarios
  313. const scenarios = [
  314. {
  315. name: 'generate without name',
  316. operation: () => generateMigrationOperation({}),
  317. expectedMessage: 'Migration name is required',
  318. },
  319. {
  320. name: 'invalid migration name',
  321. operation: () => generateMigrationOperation({ name: '123invalid' }),
  322. expectedMessage: 'must contain only letters, numbers, underscores and dashes',
  323. },
  324. ];
  325. for (const scenario of scenarios) {
  326. const result = await scenario.operation();
  327. expect(result.success).toBe(false);
  328. expect(result.message).toContain(scenario.expectedMessage);
  329. }
  330. });
  331. });
  332. },
  333. { timeout: 60_000 },
  334. );