test(up): start writing some tests for the "up" command
This commit is contained in:
parent
de5fccfa52
commit
43a220d633
5 changed files with 244 additions and 12 deletions
|
|
@ -23,6 +23,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --pretty",
|
"build": "tsc --pretty",
|
||||||
"build:watch": "tsc --pretty --watch",
|
"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)"
|
"lint": "xo --cwd=../.. $(pwd)"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ Examples:
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { default: upCommand } = await import('./commands/up.js');
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof ShowUsageError) {
|
if (error instanceof ShowUsageError) {
|
||||||
console.error(error.message, '\n');
|
console.error(error.message, '\n');
|
||||||
|
|
@ -359,4 +359,6 @@ try {
|
||||||
} else {
|
} else {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
221
packages/cli/src/commands/up.test.ts
Normal file
221
packages/cli/src/commands/up.test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -17,11 +17,13 @@ import {
|
||||||
} from '../errors.js';
|
} from '../errors.js';
|
||||||
import { type Config } from '../types.js';
|
import { type Config } from '../types.js';
|
||||||
import { withLeadingPeriod } from '../with-leading-period.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';
|
import { getDuration } from '../get-duration.js';
|
||||||
|
|
||||||
type ExtraFlags = {
|
type ExtraFlags = {
|
||||||
|
cwd?: string;
|
||||||
dry?: boolean;
|
dry?: boolean;
|
||||||
|
getMigrations?: GetMigrationsFunction;
|
||||||
};
|
};
|
||||||
|
|
||||||
const lazyDefaultReporter = async () => import('../reporters/default.js');
|
const lazyDefaultReporter = async () => import('../reporters/default.js');
|
||||||
|
|
@ -33,12 +35,13 @@ export default async function upCommand({
|
||||||
directory,
|
directory,
|
||||||
dry = false,
|
dry = false,
|
||||||
plugins = [],
|
plugins = [],
|
||||||
}: Config & ExtraFlags) {
|
cwd = process.cwd(),
|
||||||
|
getMigrations = getMigrationsOriginal,
|
||||||
|
}: Config & ExtraFlags): Promise<number> {
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
throw new MissingOptionError('directory');
|
throw new MissingOptionError('directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const storagePlugin = await getOrLoadStorage([storageConfig]);
|
const storagePlugin = await getOrLoadStorage([storageConfig]);
|
||||||
|
|
||||||
if (!storagePlugin) {
|
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 migrationFiles = await getMigrations(cwd, directory);
|
||||||
const failedEntries: MigrationMetadataFinished[] = [];
|
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]);
|
await reporter.onCollectedMigrations?.([...failedEntries, ...migrationFiles]);
|
||||||
|
|
||||||
if (migrationFiles.length === 0 || dry || failedEntries.length > 0) {
|
if (migrationFiles.length === 0 || dry || failedEntries.length > 0) {
|
||||||
|
|
@ -133,14 +136,13 @@ export default async function upCommand({
|
||||||
|
|
||||||
await reporter.onFinished?.([...failedEntries, ...finishedMigrations], error);
|
await reporter.onFinished?.([...failedEntries, ...finishedMigrations], error);
|
||||||
|
|
||||||
process.exitCode = failedEntries.length > 0 ? 1 : 0;
|
return failedEntries.length > 0 ? 1 : 0;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let lockedMigrationFiles: MigrationMetadata[] = [];
|
let lockedMigrationFiles: MigrationMetadata[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
lockedMigrationFiles = await storage.lock(migrationFiles);
|
lockedMigrationFiles = (await storage.lock(migrationFiles)) ?? [];
|
||||||
|
|
||||||
await reporter.onLockedMigrations?.(lockedMigrationFiles);
|
await reporter.onLockedMigrations?.(lockedMigrationFiles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -149,7 +151,8 @@ export default async function upCommand({
|
||||||
}
|
}
|
||||||
|
|
||||||
await reporter.onFinished?.([], error instanceof Error ? error : new Error(String(error)));
|
await reporter.onFinished?.([], error instanceof Error ? error : new Error(String(error)));
|
||||||
return;
|
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonLockedMigrations = migrationFiles.filter((migration) => !lockedMigrationFiles.includes(migration));
|
const nonLockedMigrations = migrationFiles.filter((migration) => !lockedMigrationFiles.includes(migration));
|
||||||
|
|
@ -230,6 +233,10 @@ export default async function upCommand({
|
||||||
finishedMigrations.push(finishedMigration);
|
finishedMigrations.push(finishedMigration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstFailed = finishedMigrations.find((migration) => migration.status === 'failed');
|
||||||
|
|
||||||
|
return firstFailed ? 1 : 0;
|
||||||
} finally {
|
} finally {
|
||||||
const firstFailed = finishedMigrations.find((migration) => migration.status === 'failed');
|
const firstFailed = finishedMigrations.find((migration) => migration.status === 'failed');
|
||||||
const firstError =
|
const firstError =
|
||||||
|
|
@ -243,7 +250,5 @@ export default async function upCommand({
|
||||||
|
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await reporter.onFinished?.(finishedMigrations, firstError);
|
await reporter.onFinished?.(finishedMigrations, firstError);
|
||||||
|
|
||||||
process.exitCode = firstError ? 1 : 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import fs from 'node:fs/promises';
|
||||||
import { type MigrationMetadata } from '@emigrate/plugin-tools/types';
|
import { type MigrationMetadata } from '@emigrate/plugin-tools/types';
|
||||||
import { withLeadingPeriod } from './with-leading-period.js';
|
import { withLeadingPeriod } from './with-leading-period.js';
|
||||||
|
|
||||||
|
export type GetMigrationsFunction = typeof getMigrations;
|
||||||
|
|
||||||
export const getMigrations = async (cwd: string, directory: string): Promise<MigrationMetadata[]> => {
|
export const getMigrations = async (cwd: string, directory: string): Promise<MigrationMetadata[]> => {
|
||||||
const allFilesInMigrationDirectory = await fs.readdir(path.resolve(cwd, directory), {
|
const allFilesInMigrationDirectory = await fs.readdir(path.resolve(cwd, directory), {
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue