migrate-command.e2e-spec.ts 15 KB

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