From 43a220d6333647767c9b39baad60aa3f3c7c077e Mon Sep 17 00:00:00 2001 From: Joakim Carlstein Date: Thu, 7 Dec 2023 10:48:02 +0100 Subject: [PATCH] test(up): start writing some tests for the "up" command --- packages/cli/package.json | 2 + packages/cli/src/cli.ts | 4 +- packages/cli/src/commands/up.test.ts | 221 +++++++++++++++++++++++++++ packages/cli/src/commands/up.ts | 27 ++-- packages/cli/src/get-migrations.ts | 2 + 5 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/commands/up.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 9080282..c238c9a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,6 +23,8 @@ "scripts": { "build": "tsc --pretty", "build:watch": "tsc --pretty --watch", + "test": "glob -c \"node --import tsx --test-reporter spec --test\" \"./src/**/*.test.ts\"", + "test:watch": "glob -c \"node --watch --import tsx --test-reporter spec --test\" \"./src/**/*.test.ts\"", "lint": "xo --cwd=../.. $(pwd)" }, "keywords": [ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f423486..0e12425 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -71,7 +71,7 @@ Examples: try { const { default: upCommand } = await import('./commands/up.js'); - await upCommand({ storage, reporter, directory, plugins, dry }); + process.exitCode = await upCommand({ storage, reporter, directory, plugins, dry }); } catch (error) { if (error instanceof ShowUsageError) { console.error(error.message, '\n'); @@ -359,4 +359,6 @@ try { } else { console.error(error); } + + process.exitCode = 1; } diff --git a/packages/cli/src/commands/up.test.ts b/packages/cli/src/commands/up.test.ts new file mode 100644 index 0000000..87c975d --- /dev/null +++ b/packages/cli/src/commands/up.test.ts @@ -0,0 +1,221 @@ +import { describe, it, mock, type Mock } from 'node:test'; +import assert from 'node:assert'; +import path from 'node:path'; +import { + type EmigrateReporter, + type MigrationHistoryEntry, + type MigrationMetadata, + type Storage, + type Plugin, +} from '@emigrate/plugin-tools/types'; +import upCommand from './up.js'; + +type Mocked = { + // @ts-expect-error - This is a mock + [K in keyof T]: Mock; +}; + +describe('up', () => { + it('returns 0 and finishes without an error when there are no migrations to run', async () => { + const { reporter, run } = getUpCommand([], []); + + const exitCode = await run(); + + assert.strictEqual(exitCode, 0); + assert.strictEqual(reporter.onInit.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ + { + command: 'up', + cwd: '/emigrate', + dry: false, + directory: 'migrations', + }, + ]); + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0); + assert.strictEqual(reporter.onFinished.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onFinished.mock.calls[0]?.arguments, [[], undefined]); + }); + + it('throws when there are migration file extensions without a corresponding loader plugin', async () => { + const { reporter, run } = getUpCommand(['some_file.sql'], []); + + await assert.rejects( + async () => { + return run(); + }, + { + name: 'Error [ERR_BAD_OPT]', + message: 'No loader plugin found for file extension: .sql', + }, + ); + + assert.strictEqual(reporter.onInit.mock.calls.length, 0); + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 0); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0); + assert.strictEqual(reporter.onFinished.mock.calls.length, 0); + }); + + it('throws when there are migration file extensions without a corresponding loader plugin in dry-run mode as well', async () => { + const { reporter, run } = getUpCommand(['some_file.sql'], []); + + await assert.rejects( + async () => { + return run(true); + }, + { + name: 'Error [ERR_BAD_OPT]', + message: 'No loader plugin found for file extension: .sql', + }, + ); + + assert.strictEqual(reporter.onInit.mock.calls.length, 0); + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 0); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0); + assert.strictEqual(reporter.onFinished.mock.calls.length, 0); + }); + + it('returns 1 and finishes with an error when there are failed migrations in the history', async () => { + const failedEntry = toEntry('some_failed_migration.js', 'failed'); + const { reporter, run } = getUpCommand([failedEntry.name], [failedEntry]); + + const exitCode = await run(); + + assert.strictEqual(exitCode, 1); + assert.strictEqual(reporter.onInit.mock.calls.length, 1); + assert.deepStrictEqual(reporter.onInit.mock.calls[0]?.arguments, [ + { + command: 'up', + cwd: '/emigrate', + dry: false, + directory: 'migrations', + }, + ]); + assert.strictEqual(reporter.onCollectedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onLockedMigrations.mock.calls.length, 1); + assert.strictEqual(reporter.onMigrationStart.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationSuccess.mock.calls.length, 0); + assert.strictEqual(reporter.onMigrationError.mock.calls.length, 1); + assert.strictEqual(reporter.onMigrationSkip.mock.calls.length, 0); + assert.strictEqual(reporter.onFinished.mock.calls.length, 1); + const args = reporter.onFinished.mock.calls[0]?.arguments; + assert.strictEqual(args?.length, 2); + const finishedEntry = args[0]?.[0]; + const error = args[1]; + assert.strictEqual(finishedEntry?.name, failedEntry.name); + assert.strictEqual(finishedEntry?.status, 'failed'); + assert.strictEqual(finishedEntry?.error?.cause, failedEntry.error); + assert.strictEqual(finishedEntry.error, error); + assert.strictEqual(error?.cause, failedEntry.error); + }); +}); + +function toMigration(cwd: string, directory: string, name: string): MigrationMetadata { + return { + name, + filePath: `${cwd}/${directory}/${name}`, + relativeFilePath: `${directory}/${name}`, + extension: path.extname(name), + directory, + cwd, + }; +} + +function toMigrations(cwd: string, directory: string, names: string[]): MigrationMetadata[] { + return names.map((name) => toMigration(cwd, directory, name)); +} + +function toEntry( + name: string | MigrationHistoryEntry, + status: MigrationHistoryEntry['status'] = 'done', +): MigrationHistoryEntry { + if (typeof name === 'string') { + return { + name, + status, + date: new Date(), + error: status === 'failed' ? new Error('Failed') : undefined, + }; + } + + return name; +} + +function toEntries( + names: Array, + status: MigrationHistoryEntry['status'] = 'done', +): MigrationHistoryEntry[] { + return names.map((name) => toEntry(name, status)); +} + +async function noop() { + // noop +} + +function getUpCommand( + migrationFiles: string[], + historyEntries: Array, + plugins?: Plugin[], +) { + const reporter: Mocked> = { + onFinished: mock.fn(noop), + onInit: mock.fn(noop), + onCollectedMigrations: mock.fn(noop), + onLockedMigrations: mock.fn(noop), + onNewMigration: mock.fn(noop), + onMigrationRemoveStart: mock.fn(noop), + onMigrationRemoveSuccess: mock.fn(noop), + onMigrationRemoveError: mock.fn(noop), + onMigrationStart: mock.fn(noop), + onMigrationSuccess: mock.fn(noop), + onMigrationError: mock.fn(noop), + onMigrationSkip: mock.fn(noop), + }; + + const storage: Mocked = { + lock: mock.fn(), + unlock: mock.fn(), + getHistory: mock.fn(async function* () { + yield* toEntries(historyEntries); + }), + remove: mock.fn(), + onSuccess: mock.fn(), + onError: mock.fn(), + }; + + const run = async (dry = false) => { + return upCommand({ + cwd: '/emigrate', + directory: 'migrations', + storage: { + async initializeStorage() { + return storage; + }, + }, + reporter, + dry, + plugins: plugins ?? [], + async getMigrations(cwd, directory) { + return toMigrations(cwd, directory, migrationFiles); + }, + }); + }; + + return { + reporter, + storage, + run, + }; +} diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index 1a37c71..5002547 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -17,11 +17,13 @@ import { } from '../errors.js'; import { type Config } from '../types.js'; import { withLeadingPeriod } from '../with-leading-period.js'; -import { getMigrations } from '../get-migrations.js'; +import { getMigrations as getMigrationsOriginal, type GetMigrationsFunction } from '../get-migrations.js'; import { getDuration } from '../get-duration.js'; type ExtraFlags = { + cwd?: string; dry?: boolean; + getMigrations?: GetMigrationsFunction; }; const lazyDefaultReporter = async () => import('../reporters/default.js'); @@ -33,12 +35,13 @@ export default async function upCommand({ directory, dry = false, plugins = [], -}: Config & ExtraFlags) { + cwd = process.cwd(), + getMigrations = getMigrationsOriginal, +}: Config & ExtraFlags): Promise { if (!directory) { throw new MissingOptionError('directory'); } - const cwd = process.cwd(); const storagePlugin = await getOrLoadStorage([storageConfig]); if (!storagePlugin) { @@ -55,8 +58,6 @@ export default async function upCommand({ ); } - await reporter.onInit?.({ command: 'up', cwd, dry, directory }); - const migrationFiles = await getMigrations(cwd, directory); const failedEntries: MigrationMetadataFinished[] = []; @@ -111,6 +112,8 @@ export default async function upCommand({ } } + await reporter.onInit?.({ command: 'up', cwd, dry, directory }); + await reporter.onCollectedMigrations?.([...failedEntries, ...migrationFiles]); if (migrationFiles.length === 0 || dry || failedEntries.length > 0) { @@ -133,14 +136,13 @@ export default async function upCommand({ await reporter.onFinished?.([...failedEntries, ...finishedMigrations], error); - process.exitCode = failedEntries.length > 0 ? 1 : 0; - return; + return failedEntries.length > 0 ? 1 : 0; } let lockedMigrationFiles: MigrationMetadata[] = []; try { - lockedMigrationFiles = await storage.lock(migrationFiles); + lockedMigrationFiles = (await storage.lock(migrationFiles)) ?? []; await reporter.onLockedMigrations?.(lockedMigrationFiles); } catch (error) { @@ -149,7 +151,8 @@ export default async function upCommand({ } await reporter.onFinished?.([], error instanceof Error ? error : new Error(String(error))); - return; + + return 1; } const nonLockedMigrations = migrationFiles.filter((migration) => !lockedMigrationFiles.includes(migration)); @@ -230,6 +233,10 @@ export default async function upCommand({ finishedMigrations.push(finishedMigration); } } + + const firstFailed = finishedMigrations.find((migration) => migration.status === 'failed'); + + return firstFailed ? 1 : 0; } finally { const firstFailed = finishedMigrations.find((migration) => migration.status === 'failed'); const firstError = @@ -243,7 +250,5 @@ export default async function upCommand({ await cleanup(); await reporter.onFinished?.(finishedMigrations, firstError); - - process.exitCode = firstError ? 1 : 0; } } diff --git a/packages/cli/src/get-migrations.ts b/packages/cli/src/get-migrations.ts index 4b63ebb..0da9f6b 100644 --- a/packages/cli/src/get-migrations.ts +++ b/packages/cli/src/get-migrations.ts @@ -3,6 +3,8 @@ import fs from 'node:fs/promises'; import { type MigrationMetadata } from '@emigrate/plugin-tools/types'; import { withLeadingPeriod } from './with-leading-period.js'; +export type GetMigrationsFunction = typeof getMigrations; + export const getMigrations = async (cwd: string, directory: string): Promise => { const allFilesInMigrationDirectory = await fs.readdir(path.resolve(cwd, directory), { withFileTypes: true,