test(up): start writing some tests for the "up" command

This commit is contained in:
Joakim Carlstein 2023-12-07 10:48:02 +01:00
parent de5fccfa52
commit 43a220d633
5 changed files with 244 additions and 12 deletions

View file

@ -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;
}

View file

@ -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<T> = {
// @ts-expect-error - This is a mock
[K in keyof T]: Mock<T[K]>;
};
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<string | MigrationHistoryEntry>,
status: MigrationHistoryEntry['status'] = 'done',
): MigrationHistoryEntry[] {
return names.map((name) => toEntry(name, status));
}
async function noop() {
// noop
}
function getUpCommand(
migrationFiles: string[],
historyEntries: Array<string | MigrationHistoryEntry>,
plugins?: Plugin[],
) {
const reporter: Mocked<Required<EmigrateReporter>> = {
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<Storage> = {
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,
};
}

View file

@ -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<number> {
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;
}
}

View file

@ -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<MigrationMetadata[]> => {
const allFilesInMigrationDirectory = await fs.readdir(path.resolve(cwd, directory), {
withFileTypes: true,