From f1f7fb67da7e82b0092e33054205769b5ff184a3 Mon Sep 17 00:00:00 2001 From: pieh Date: Wed, 22 Apr 2026 21:27:09 +0200 Subject: [PATCH 1/5] test: mock readdir and fileExistsAsync for local migrations --- .../unit/commands/database/db-status.test.ts | 93 +++++++++++++++---- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/tests/unit/commands/database/db-status.test.ts b/tests/unit/commands/database/db-status.test.ts index 2fd5b9450d6..27c38b65953 100644 --- a/tests/unit/commands/database/db-status.test.ts +++ b/tests/unit/commands/database/db-status.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' const { mockReaddir, mockReadFile, + mockFileExistsAsync, mockConnectToDatabase, mockDetectExisting, mockQuery, @@ -13,6 +14,7 @@ const { } = vi.hoisted(() => { const mockReaddir = vi.fn() const mockReadFile = vi.fn() + const mockFileExistsAsync = vi.fn() const mockQuery = vi.fn() const mockCleanup = vi.fn().mockResolvedValue(undefined) const mockConnectToDatabase = vi.fn() @@ -23,6 +25,7 @@ const { return { mockReaddir, mockReadFile, + mockFileExistsAsync, mockConnectToDatabase, mockDetectExisting, mockQuery, @@ -64,6 +67,12 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ }, })) +vi.mock('../../../../src/lib/fs.js', async (importOriginal) => ({ + ...(await importOriginal()), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + fileExistsAsync: (path: string) => mockFileExistsAsync(path), +})) + vi.mock('../../../../src/commands/database/util/db-connection.js', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return connectToDatabase: (...args: unknown[]) => mockConnectToDatabase(...args), @@ -81,11 +90,61 @@ const LOCAL_CONN_NO_CREDS = 'postgres://localhost:5432/postgres' const BRANCH_CONN = 'postgres://admin:secret@branch-host.neon.tech/db' const PROD_CONN = 'postgres://owner:prodsecret@prod-host.neon.tech/db' -const makeDirents = (names: string[]) => - names.map((name) => ({ - name, - isDirectory: () => true, - })) +interface MockFSNode { + files?: Record + dirs?: Record +} + +const DEFAULT_MOCK_FS_ROOT = '/project/netlify/database/migrations' + +const mockFS = (tree: MockFSNode, { root = DEFAULT_MOCK_FS_ROOT }: { root?: string } = {}) => { + const resolve = (absolutePath: string): { kind: 'dir'; node: MockFSNode } | { kind: 'file' } | null => { + const normalizedRoot = root.replace(/\/+$/, '') + if (absolutePath !== normalizedRoot && !absolutePath.startsWith(`${normalizedRoot}/`)) { + return null + } + const relative = absolutePath.slice(normalizedRoot.length).replace(/^\/+/, '') + if (relative === '') return { kind: 'dir', node: tree } + const parts = relative.split('/') + let cursor: MockFSNode = tree + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i] + const isLast = i === parts.length - 1 + if (isLast && cursor.files && part in cursor.files) return { kind: 'file' } + if (cursor.dirs && part in cursor.dirs) { + cursor = cursor.dirs[part] + if (isLast) return { kind: 'dir', node: cursor } + continue + } + return null + } + return null + } + + mockReaddir.mockImplementation((path: unknown) => { + const resolved = typeof path === 'string' ? resolve(path) : null + if (!resolved || resolved.kind !== 'dir') { + return Promise.reject(Object.assign(new Error(`ENOENT: ${String(path)}`), { code: 'ENOENT' })) + } + const dirEntries = Object.keys(resolved.node.dirs ?? {}).map((name) => ({ + name, + isDirectory: () => true, + isFile: () => false, + })) + const fileEntries = Object.keys(resolved.node.files ?? {}).map((name) => ({ + name, + isDirectory: () => false, + isFile: () => true, + })) + return Promise.resolve([...dirEntries, ...fileEntries]) + }) + + mockFileExistsAsync.mockImplementation((path: unknown) => Promise.resolve(typeof path === 'string' && resolve(path) !== null)) +} + +const migrationsTree = (names: string[]): MockFSNode => ({ + dirs: Object.fromEntries(names.map((name) => [name, { files: { 'migration.sql': '' } }])), +}) function createMockCommand( overrides: { siteRoot?: string | null; migrationsPath?: string | null; siteId?: string | null } = {}, @@ -178,7 +237,7 @@ beforeEach(() => { logMessages.length = 0 jsonMessages.length = 0 vi.clearAllMocks() - mockReaddir.mockResolvedValue([]) + mockFS({}) mockCleanup.mockResolvedValue(undefined) mockLocalAppliedRows([]) setupFetchRouter({ siteDatabase: null }) @@ -288,7 +347,7 @@ describe('statusDb', () => { test('still connects and reads migration state when no local database is already running', async () => { mockDetectExisting.mockReturnValue(null) mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + mockFS(migrationsTree(['0001_a', '0002_b'])) await statusDb({ json: true }, createMockCommand()) @@ -315,7 +374,7 @@ describe('statusDb', () => { test('default output hints at starting a persistent local database when none is running', async () => { mockDetectExisting.mockReturnValue(null) mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + mockFS(migrationsTree(['0001_a', '0002_b'])) await statusDb({}, createMockCommand()) @@ -338,7 +397,7 @@ describe('statusDb', () => { test('reports applied and pending correctly', async () => { mockLocalAppliedRows(['0001_a', '0002_b']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c'])) + mockFS(migrationsTree(['0001_a', '0002_b', '0003_c'])) await statusDb({ json: true }, createMockCommand()) @@ -370,7 +429,7 @@ describe('statusDb', () => { test('default output shows applied and pending as bullets', async () => { mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b'])) + mockFS(migrationsTree(['0001_a', '0002_b'])) await statusDb({}, createMockCommand()) @@ -383,7 +442,7 @@ describe('statusDb', () => { test('default output includes the apply-command hint when pending migrations exist on local', async () => { mockLocalAppliedRows([]) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -392,7 +451,7 @@ describe('statusDb', () => { test('default output omits the apply-command hint when there are no pending migrations', async () => { mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -451,7 +510,7 @@ describe('statusDb', () => { test('renders an installed statement under Package when installed', async () => { await statusDb({}, createMockCommand()) - + console.log({ wat: logMessages.join('\n') }) expect(logMessages.join('\n')).toContain('The @netlify/database package is installed') }) @@ -493,7 +552,7 @@ describe('statusDb', () => { test('always renders the immutability note below the Applied migrations list', async () => { mockLocalAppliedRows(['0001_a']) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -540,7 +599,7 @@ describe('statusDb', () => { test('uses the dynamic command in the apply-pending hint', async () => { mockLocalAppliedRows([]) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({}, createMockCommand()) @@ -595,7 +654,7 @@ describe('statusDb', () => { ], }, }) - mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c'])) + mockFS(migrationsTree(['0001_a', '0002_b', '0003_c'])) await statusDb({ branch: 'feature-x', json: true }, createMockCommand()) @@ -628,7 +687,7 @@ describe('statusDb', () => { branch: { 'feature-x': { connection_string: BRANCH_CONN } }, migrations: { 'feature-x': [] }, }) - mockReaddir.mockResolvedValue(makeDirents(['0001_a'])) + mockFS(migrationsTree(['0001_a'])) await statusDb({ branch: 'feature-x' }, createMockCommand()) From 2aa2cadd8b571750d8314dbc20610ad116742c96 Mon Sep 17 00:00:00 2001 From: pieh Date: Wed, 22 Apr 2026 21:58:52 +0200 Subject: [PATCH 2/5] fix: match migration finding for database status command with netlify/build behavior --- src/commands/database/db-status.ts | 32 ++++++-- .../unit/commands/database/db-status.test.ts | 79 +++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/commands/database/db-status.ts b/src/commands/database/db-status.ts index 5d1748b18ea..0f65c8bfded 100644 --- a/src/commands/database/db-status.ts +++ b/src/commands/database/db-status.ts @@ -10,6 +10,8 @@ import { } from './util/applied-migrations.js' import { connectToDatabase, detectExistingLocalConnectionString } from './util/db-connection.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' +import { fileExistsAsync } from '../../lib/fs.js' +import { join } from 'path' export interface DatabaseStatusOptions { branch?: string @@ -50,7 +52,7 @@ const logConnectCommands = () => { } const parseVersion = (name: string): number | null => { - const match = /^(\d+)[_-]/.exec(name) + const match = /^(\d+)_/.exec(name) if (!match) { return null } @@ -58,6 +60,8 @@ const parseVersion = (name: string): number | null => { return Number.isFinite(parsed) ? parsed : null } +const MIGRATION_NAME_PATTERN = /^\d+_[a-z0-9_-]+$/ + const readLocalMigrations = async (migrationsDirectory: string): Promise => { let entries try { @@ -69,16 +73,34 @@ const readLocalMigrations = async (migrationsDirectory: string): Promise a.version - b.version) return migrations diff --git a/tests/unit/commands/database/db-status.test.ts b/tests/unit/commands/database/db-status.test.ts index 27c38b65953..a94a62f8661 100644 --- a/tests/unit/commands/database/db-status.test.ts +++ b/tests/unit/commands/database/db-status.test.ts @@ -479,6 +479,85 @@ describe('statusDb', () => { }) }) + describe('local migration discovery', () => { + test('ignores directories that do not contain a migration.sql file', async () => { + mockFS({ + dirs: { + '0001_with_sql': { files: { 'migration.sql': '' } }, + '0002_without_sql': {}, + '0003_wrong_file': { files: { 'readme.md': '' } }, + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [{ version: 1, name: '0001_with_sql' }], + }) + }) + + test('includes .sql files sitting directly under the migrations directory', async () => { + mockFS({ + files: { + '0001_a.sql': '', + '0002_b.sql': '', + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [ + { version: 1, name: '0001_a' }, + { version: 2, name: '0002_b' }, + ], + }) + }) + + test('ignores directories whose name does not match the migration pattern', async () => { + mockFS({ + dirs: { + '0001_valid': { files: { 'migration.sql': '' } }, + '0002_with-hyphen': { files: { 'migration.sql': '' } }, + 'not-a-migration': { files: { 'migration.sql': '' } }, + '0003_UPPERCASE': { files: { 'migration.sql': '' } }, + '0004-hyphen-separator': { files: { 'migration.sql': '' } }, + no_leading_digits: { files: { 'migration.sql': '' } }, + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [ + { version: 1, name: '0001_valid' }, + { version: 2, name: '0002_with-hyphen' }, + ], + }) + }) + + test('ignores .sql files whose name does not match the migration pattern', async () => { + mockFS({ + files: { + '0001_valid.sql': '', + '0002_with-hyphen.sql': '', + 'random.sql': '', + '0003_UPPERCASE.sql': '', + '0004-hyphen-separator.sql': '', + }, + }) + + await statusDb({ json: true }, createMockCommand()) + + expect(jsonMessages[0]).toMatchObject({ + pending: [ + { version: 1, name: '0001_valid' }, + { version: 2, name: '0002_with-hyphen' }, + ], + }) + }) + }) + describe('secondary descriptive lines', () => { test('renders a descriptive line under Enabled when true', async () => { process.env.NETLIFY_DB_URL = 'postgres://x/y' From e6c036d20606b2709020603e7c217229adf19b5c Mon Sep 17 00:00:00 2001 From: pieh Date: Wed, 22 Apr 2026 22:06:01 +0200 Subject: [PATCH 3/5] chore: format --- tests/unit/commands/database/db-status.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/commands/database/db-status.test.ts b/tests/unit/commands/database/db-status.test.ts index a94a62f8661..e1fd671e352 100644 --- a/tests/unit/commands/database/db-status.test.ts +++ b/tests/unit/commands/database/db-status.test.ts @@ -139,7 +139,9 @@ const mockFS = (tree: MockFSNode, { root = DEFAULT_MOCK_FS_ROOT }: { root?: stri return Promise.resolve([...dirEntries, ...fileEntries]) }) - mockFileExistsAsync.mockImplementation((path: unknown) => Promise.resolve(typeof path === 'string' && resolve(path) !== null)) + mockFileExistsAsync.mockImplementation((path: unknown) => + Promise.resolve(typeof path === 'string' && resolve(path) !== null), + ) } const migrationsTree = (names: string[]): MockFSNode => ({ From df2e11421fe6ac9f3ec7c7b763f2f26886b74e24 Mon Sep 17 00:00:00 2001 From: pieh Date: Wed, 22 Apr 2026 22:23:00 +0200 Subject: [PATCH 4/5] test: maybe fix tests on windows --- src/commands/database/db-status.ts | 2 +- tests/unit/commands/database/db-status.test.ts | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/commands/database/db-status.ts b/src/commands/database/db-status.ts index 0f65c8bfded..0cfdd22e143 100644 --- a/src/commands/database/db-status.ts +++ b/src/commands/database/db-status.ts @@ -1,4 +1,5 @@ import { readFile, readdir } from 'fs/promises' +import { join } from 'path' import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js' import BaseCommand from '../base-command.js' @@ -11,7 +12,6 @@ import { import { connectToDatabase, detectExistingLocalConnectionString } from './util/db-connection.js' import { resolveMigrationsDirectory } from './util/migrations-path.js' import { fileExistsAsync } from '../../lib/fs.js' -import { join } from 'path' export interface DatabaseStatusOptions { branch?: string diff --git a/tests/unit/commands/database/db-status.test.ts b/tests/unit/commands/database/db-status.test.ts index e1fd671e352..b429f849881 100644 --- a/tests/unit/commands/database/db-status.test.ts +++ b/tests/unit/commands/database/db-status.test.ts @@ -1,3 +1,5 @@ +import { relative, sep } from 'path' + import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' const { @@ -99,13 +101,10 @@ const DEFAULT_MOCK_FS_ROOT = '/project/netlify/database/migrations' const mockFS = (tree: MockFSNode, { root = DEFAULT_MOCK_FS_ROOT }: { root?: string } = {}) => { const resolve = (absolutePath: string): { kind: 'dir'; node: MockFSNode } | { kind: 'file' } | null => { - const normalizedRoot = root.replace(/\/+$/, '') - if (absolutePath !== normalizedRoot && !absolutePath.startsWith(`${normalizedRoot}/`)) { - return null - } - const relative = absolutePath.slice(normalizedRoot.length).replace(/^\/+/, '') - if (relative === '') return { kind: 'dir', node: tree } - const parts = relative.split('/') + const rel = relative(root, absolutePath) + if (rel === '') return { kind: 'dir', node: tree } + if (rel.startsWith('..')) return null + const parts = rel.split(sep) let cursor: MockFSNode = tree for (let i = 0; i < parts.length; i += 1) { const part = parts[i] From 58ff71548a2f3ba769093ef06bcf92332aa2196e Mon Sep 17 00:00:00 2001 From: pieh Date: Wed, 22 Apr 2026 22:26:51 +0200 Subject: [PATCH 5/5] chore: remove test debug log --- tests/unit/commands/database/db-status.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/commands/database/db-status.test.ts b/tests/unit/commands/database/db-status.test.ts index b429f849881..3f32af634bb 100644 --- a/tests/unit/commands/database/db-status.test.ts +++ b/tests/unit/commands/database/db-status.test.ts @@ -590,7 +590,6 @@ describe('statusDb', () => { test('renders an installed statement under Package when installed', async () => { await statusDb({}, createMockCommand()) - console.log({ wat: logMessages.join('\n') }) expect(logMessages.join('\n')).toContain('The @netlify/database package is installed') })