|
|
@@ -0,0 +1,411 @@
|
|
|
+/**
|
|
|
+ * E2E tests for the migrate command
|
|
|
+ *
|
|
|
+ * To run these tests:
|
|
|
+ * npm run vitest -- --config e2e/vitest.e2e.config.mts
|
|
|
+ */
|
|
|
+import * as fs from 'fs-extra';
|
|
|
+import * as path from 'path';
|
|
|
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
+
|
|
|
+import {
|
|
|
+ generateMigrationOperation,
|
|
|
+ revertMigrationOperation,
|
|
|
+ runMigrationsOperation,
|
|
|
+} from '../src/commands/migrate/migration-operations';
|
|
|
+
|
|
|
+const TEST_PROJECT_DIR = path.join(__dirname, 'fixtures', 'test-project');
|
|
|
+const MIGRATIONS_DIR = path.join(TEST_PROJECT_DIR, 'migrations');
|
|
|
+
|
|
|
+describe('Migrate Command E2E', () => {
|
|
|
+ let originalCwd: string;
|
|
|
+
|
|
|
+ beforeAll(() => {
|
|
|
+ // Save the original working directory
|
|
|
+ originalCwd = process.cwd();
|
|
|
+ });
|
|
|
+
|
|
|
+ beforeEach(async () => {
|
|
|
+ // Clean up migrations directory before each test
|
|
|
+ await fs.emptyDir(MIGRATIONS_DIR);
|
|
|
+ // Clean up test database
|
|
|
+ const dbPath = path.join(TEST_PROJECT_DIR, 'test.db');
|
|
|
+ if (await fs.pathExists(dbPath)) {
|
|
|
+ await fs.remove(dbPath);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ // Restore original working directory
|
|
|
+ process.chdir(originalCwd);
|
|
|
+ // Clean up after tests
|
|
|
+ await fs.emptyDir(MIGRATIONS_DIR);
|
|
|
+ const dbPath = path.join(TEST_PROJECT_DIR, 'test.db');
|
|
|
+ if (await fs.pathExists(dbPath)) {
|
|
|
+ await fs.remove(dbPath);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('generateMigrationOperation', () => {
|
|
|
+ it('should fail when not in a Vendure project directory', async () => {
|
|
|
+ // Run from a non-Vendure directory
|
|
|
+ process.chdir(__dirname);
|
|
|
+
|
|
|
+ const result = await generateMigrationOperation({ name: 'test-migration' });
|
|
|
+
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toContain('Not in a Vendure project directory');
|
|
|
+ expect(result.migrationName).toBeUndefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should generate a migration when in a valid Vendure project', async () => {
|
|
|
+ // Change to test project directory
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ const result = await generateMigrationOperation({
|
|
|
+ name: 'AddTestEntity',
|
|
|
+ outputDir: MIGRATIONS_DIR,
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(result.success).toBe(true);
|
|
|
+ expect(result.migrationName).toBeDefined();
|
|
|
+ expect(result.message).toContain('New migration generated');
|
|
|
+
|
|
|
+ // Verify migration file was created
|
|
|
+ const files = await fs.readdir(MIGRATIONS_DIR);
|
|
|
+ const migrationFile = files.find(f => f.includes('AddTestEntity'));
|
|
|
+ expect(migrationFile).toBeDefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle invalid migration names correctly', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ const invalidNames = [
|
|
|
+ '123-invalid', // starts with number
|
|
|
+ 'test migration', // contains space
|
|
|
+ 'test@migration', // special character
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const name of invalidNames) {
|
|
|
+ const result = await generateMigrationOperation({ name });
|
|
|
+
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toContain(
|
|
|
+ 'must contain only letters, numbers, underscores and dashes',
|
|
|
+ );
|
|
|
+ expect(result.migrationName).toBeUndefined();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should accept valid migration names', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ const validNames = [
|
|
|
+ 'TestMigration',
|
|
|
+ 'test-migration',
|
|
|
+ 'test_migration',
|
|
|
+ 'Migration123',
|
|
|
+ 'ab', // minimum 2 characters
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const name of validNames) {
|
|
|
+ const result = await generateMigrationOperation({
|
|
|
+ name,
|
|
|
+ outputDir: MIGRATIONS_DIR,
|
|
|
+ });
|
|
|
+
|
|
|
+ // Since synchronize is false, generateMigration will create the initial migration
|
|
|
+ // The first time it runs, it will generate all tables
|
|
|
+ // Subsequent runs may report no changes
|
|
|
+ // Both are valid outcomes
|
|
|
+ expect(result.success).toBe(true);
|
|
|
+ expect(result.message).toBeDefined();
|
|
|
+
|
|
|
+ // Clean up the generated migration file for next iteration
|
|
|
+ const files = await fs.readdir(MIGRATIONS_DIR);
|
|
|
+ for (const file of files) {
|
|
|
+ if (file.includes(name)) {
|
|
|
+ await fs.remove(path.join(MIGRATIONS_DIR, file));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle missing name parameter', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ const result = await generateMigrationOperation({});
|
|
|
+
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toContain('Migration name is required');
|
|
|
+ expect(result.migrationName).toBeUndefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should use custom output directory when specified', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+ const customDir = path.join(TEST_PROJECT_DIR, 'custom-migrations');
|
|
|
+ await fs.ensureDir(customDir);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await generateMigrationOperation({
|
|
|
+ name: 'CustomDirTest',
|
|
|
+ outputDir: customDir,
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(result.success).toBe(true);
|
|
|
+
|
|
|
+ // Verify migration was created in custom directory
|
|
|
+ const files = await fs.readdir(customDir);
|
|
|
+ const migrationFile = files.find(f => f.includes('CustomDirTest'));
|
|
|
+ expect(migrationFile).toBeDefined();
|
|
|
+ } finally {
|
|
|
+ await fs.remove(customDir);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle TypeORM config errors gracefully', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // Temporarily rename the vendure config to simulate a missing config
|
|
|
+ const configPath = path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.ts');
|
|
|
+ const backupPath = path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.backup.ts');
|
|
|
+ await fs.move(configPath, backupPath);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await generateMigrationOperation({ name: 'FailTest' });
|
|
|
+
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toBeDefined();
|
|
|
+ expect(result.migrationName).toBeUndefined();
|
|
|
+ } finally {
|
|
|
+ await fs.move(backupPath, configPath);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('runMigrationsOperation', () => {
|
|
|
+ it('should fail when not in a Vendure project directory', async () => {
|
|
|
+ process.chdir(__dirname);
|
|
|
+
|
|
|
+ const result = await runMigrationsOperation();
|
|
|
+
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toContain('Not in a Vendure project directory');
|
|
|
+ expect(result.migrationsRan).toBeUndefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should report no pending migrations when none exist', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ const result = await runMigrationsOperation();
|
|
|
+
|
|
|
+ expect(result.success).toBe(true);
|
|
|
+ expect(result.message).toContain('No pending migrations found');
|
|
|
+ expect(result.migrationsRan).toBeDefined();
|
|
|
+ expect(result.migrationsRan).toHaveLength(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should run pending migrations successfully', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // First generate a migration
|
|
|
+ const generateResult = await generateMigrationOperation({
|
|
|
+ name: 'TestMigration',
|
|
|
+ outputDir: MIGRATIONS_DIR,
|
|
|
+ });
|
|
|
+ expect(generateResult.success).toBe(true);
|
|
|
+
|
|
|
+ // Then run migrations
|
|
|
+ const runResult = await runMigrationsOperation();
|
|
|
+
|
|
|
+ expect(runResult.success).toBe(true);
|
|
|
+ expect(runResult.message).toContain('Successfully ran');
|
|
|
+ expect(runResult.migrationsRan).toBeDefined();
|
|
|
+ expect(runResult.migrationsRan?.length).toBeGreaterThan(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle database connection errors gracefully', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // Ensure a clean module state before mocking
|
|
|
+ vi.resetModules();
|
|
|
+
|
|
|
+ // Mock the loadVendureConfigFile helper to return a config with an invalid database path
|
|
|
+ vi.doMock('../src/commands/migrate/load-vendure-config-file', async () => {
|
|
|
+ const { config: realConfig }: { config: any } = await vi.importActual(
|
|
|
+ path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.ts'),
|
|
|
+ );
|
|
|
+
|
|
|
+ return {
|
|
|
+ __esModule: true,
|
|
|
+ loadVendureConfigFile: () =>
|
|
|
+ Promise.resolve({
|
|
|
+ ...realConfig,
|
|
|
+ dbConnectionOptions: {
|
|
|
+ ...realConfig.dbConnectionOptions,
|
|
|
+ database: '/nonexistent/dir/test.db',
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // Re-import the operation after the mock so that it picks up the mocked helper
|
|
|
+ const { runMigrationsOperation: runMigrationsWithInvalidDb } = await import(
|
|
|
+ '../src/commands/migrate/migration-operations'
|
|
|
+ );
|
|
|
+
|
|
|
+ const result = await runMigrationsWithInvalidDb();
|
|
|
+
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toBeDefined();
|
|
|
+ expect(result.migrationsRan).toBeUndefined();
|
|
|
+
|
|
|
+ // Clean up mock for subsequent tests
|
|
|
+ vi.unmock('../src/commands/migrate/load-vendure-config-file');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('revertMigrationOperation', () => {
|
|
|
+ it('should fail when not in a Vendure project directory', async () => {
|
|
|
+ process.chdir(__dirname);
|
|
|
+
|
|
|
+ const result = await revertMigrationOperation();
|
|
|
+
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toContain('Not in a Vendure project directory');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should revert the last migration successfully', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // Generate and run a migration first
|
|
|
+ const generateResult = await generateMigrationOperation({
|
|
|
+ name: 'RevertTest',
|
|
|
+ outputDir: MIGRATIONS_DIR,
|
|
|
+ });
|
|
|
+ expect(generateResult.success).toBe(true);
|
|
|
+
|
|
|
+ const runResult = await runMigrationsOperation();
|
|
|
+ expect(runResult.success).toBe(true);
|
|
|
+
|
|
|
+ // Now revert
|
|
|
+ const revertResult = await revertMigrationOperation();
|
|
|
+
|
|
|
+ expect(revertResult.success).toBe(true);
|
|
|
+ expect(revertResult.message).toBe('Successfully reverted last migration');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle no migrations to revert gracefully', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // Try to revert when no migrations have been run
|
|
|
+ const result = await revertMigrationOperation();
|
|
|
+
|
|
|
+ // This might fail or succeed depending on TypeORM behavior
|
|
|
+ // The important thing is it doesn't throw and returns a structured result
|
|
|
+ expect(result).toHaveProperty('success');
|
|
|
+ expect(result).toHaveProperty('message');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('Integration scenarios', () => {
|
|
|
+ it('should handle a complete migration workflow', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // 1. Generate first migration
|
|
|
+ const generate1 = await generateMigrationOperation({
|
|
|
+ name: 'InitialSchema',
|
|
|
+ outputDir: MIGRATIONS_DIR,
|
|
|
+ });
|
|
|
+ expect(generate1.success).toBe(true);
|
|
|
+
|
|
|
+ // 2. Run migrations
|
|
|
+ const run1 = await runMigrationsOperation();
|
|
|
+ expect(run1.success).toBe(true);
|
|
|
+
|
|
|
+ // Since there are no actual schema changes, migrations might be empty
|
|
|
+ // This is expected behavior
|
|
|
+
|
|
|
+ // 3. Generate second migration
|
|
|
+ const generate2 = await generateMigrationOperation({
|
|
|
+ name: 'AddColumns',
|
|
|
+ outputDir: MIGRATIONS_DIR,
|
|
|
+ });
|
|
|
+ expect(generate2.success).toBe(true);
|
|
|
+
|
|
|
+ // 4. Try to run migrations again
|
|
|
+ const run2 = await runMigrationsOperation();
|
|
|
+ expect(run2.success).toBe(true);
|
|
|
+
|
|
|
+ // Since no actual schema changes, this might have 0 migrations
|
|
|
+ // which is acceptable
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle concurrent operations gracefully', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // Try to run multiple operations concurrently
|
|
|
+ const operations = [
|
|
|
+ generateMigrationOperation({ name: 'Concurrent1', outputDir: MIGRATIONS_DIR }),
|
|
|
+ generateMigrationOperation({ name: 'Concurrent2', outputDir: MIGRATIONS_DIR }),
|
|
|
+ generateMigrationOperation({ name: 'Concurrent3', outputDir: MIGRATIONS_DIR }),
|
|
|
+ ];
|
|
|
+
|
|
|
+ const results = await Promise.all(operations);
|
|
|
+
|
|
|
+ // All should complete without throwing
|
|
|
+ results.forEach(result => {
|
|
|
+ expect(result).toHaveProperty('success');
|
|
|
+ expect(result).toHaveProperty('message');
|
|
|
+ });
|
|
|
+
|
|
|
+ // At least some should succeed
|
|
|
+ const successCount = results.filter(r => r.success).length;
|
|
|
+ expect(successCount).toBeGreaterThan(0);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('Error recovery', () => {
|
|
|
+ it('should recover from interrupted migration generation', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // Create a partial migration file to simulate interruption
|
|
|
+ const partialFile = path.join(MIGRATIONS_DIR, '1234567890-Partial.ts');
|
|
|
+ await fs.writeFile(partialFile, 'export class Partial1234567890 {}');
|
|
|
+
|
|
|
+ // Should still be able to generate new migrations
|
|
|
+ const result = await generateMigrationOperation({
|
|
|
+ name: 'RecoveryTest',
|
|
|
+ outputDir: MIGRATIONS_DIR,
|
|
|
+ });
|
|
|
+
|
|
|
+ // The operation should complete successfully
|
|
|
+ expect(result.success).toBe(true);
|
|
|
+ expect(result.message).toBeDefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should provide helpful error messages for common issues', async () => {
|
|
|
+ process.chdir(TEST_PROJECT_DIR);
|
|
|
+
|
|
|
+ // Test various error scenarios
|
|
|
+ const scenarios = [
|
|
|
+ {
|
|
|
+ name: 'generate without name',
|
|
|
+ operation: () => generateMigrationOperation({}),
|
|
|
+ expectedMessage: 'Migration name is required',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'invalid migration name',
|
|
|
+ operation: () => generateMigrationOperation({ name: '123invalid' }),
|
|
|
+ expectedMessage: 'must contain only letters, numbers, underscores and dashes',
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const scenario of scenarios) {
|
|
|
+ const result = await scenario.operation();
|
|
|
+ expect(result.success).toBe(false);
|
|
|
+ expect(result.message).toContain(scenario.expectedMessage);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|