* Increase coverage * Add comprehensive LLM development documentation - Add CLAUDE.md as main documentation index - Create 8 detailed documentation files in docs/: - architecture.md: Tech stack, data models, auth system - directory-structure.md: Complete file tree with paths - backend-patterns.md: Module architecture and patterns - database.md: Models, migrations, and workflows - development-workflow.md: Setup and daily development - code-conventions.md: Style guide and best practices - testing.md: Test organization and patterns - common-tasks.md: How-to guides for frequent tasks - Update .gitignore to allow project-level CLAUDE.md - 4,285 lines of comprehensive documentation - Organized for easy navigation with cross-links - LLM-optimized with absolute paths and code examples * fixup! Add comprehensive LLM development documentation
360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
const {
|
|
safeAddColumns,
|
|
safeCreateTable,
|
|
safeAddIndex,
|
|
safeRemoveColumn,
|
|
safeChangeColumn,
|
|
} = require('../../../utils/migration-utils');
|
|
|
|
describe('migration-utils', () => {
|
|
let queryInterface;
|
|
|
|
beforeEach(() => {
|
|
queryInterface = {
|
|
showAllTables: jest.fn(),
|
|
describeTable: jest.fn(),
|
|
addColumn: jest.fn(),
|
|
removeColumn: jest.fn(),
|
|
createTable: jest.fn(),
|
|
showIndex: jest.fn(),
|
|
addIndex: jest.fn(),
|
|
changeColumn: jest.fn(),
|
|
sequelize: {
|
|
getDialect: jest.fn().mockReturnValue('postgres'),
|
|
query: jest.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('safeAddColumns', () => {
|
|
it('should skip when table does not exist', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['other_table']);
|
|
await safeAddColumns(queryInterface, 'missing_table', [
|
|
{ name: 'col1', definition: { type: 'TEXT' } },
|
|
]);
|
|
expect(queryInterface.addColumn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should add column when it does not exist', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {},
|
|
title: {},
|
|
});
|
|
const definition = { type: 'TEXT' };
|
|
|
|
await safeAddColumns(queryInterface, 'tasks', [
|
|
{ name: 'description', definition },
|
|
]);
|
|
|
|
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
|
'tasks',
|
|
'description',
|
|
definition
|
|
);
|
|
});
|
|
|
|
it('should skip column when it already exists', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {},
|
|
title: {},
|
|
});
|
|
|
|
await safeAddColumns(queryInterface, 'tasks', [
|
|
{ name: 'title', definition: { type: 'TEXT' } },
|
|
]);
|
|
|
|
expect(queryInterface.addColumn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should add multiple columns, skipping existing ones', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {},
|
|
title: {},
|
|
});
|
|
|
|
await safeAddColumns(queryInterface, 'tasks', [
|
|
{ name: 'title', definition: { type: 'TEXT' } }, // exists
|
|
{ name: 'priority', definition: { type: 'INTEGER' } }, // new
|
|
{ name: 'due_date', definition: { type: 'DATE' } }, // new
|
|
]);
|
|
|
|
expect(queryInterface.addColumn).toHaveBeenCalledTimes(2);
|
|
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
|
'tasks',
|
|
'priority',
|
|
{ type: 'INTEGER' }
|
|
);
|
|
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
|
'tasks',
|
|
'due_date',
|
|
{ type: 'DATE' }
|
|
);
|
|
});
|
|
|
|
it('should re-throw errors from addColumn', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({ id: {} });
|
|
queryInterface.addColumn.mockRejectedValue(new Error('DB error'));
|
|
|
|
await expect(
|
|
safeAddColumns(queryInterface, 'tasks', [
|
|
{ name: 'col', definition: { type: 'TEXT' } },
|
|
])
|
|
).rejects.toThrow('DB error');
|
|
});
|
|
});
|
|
|
|
describe('safeCreateTable', () => {
|
|
it('should create table when it does not exist', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue([]);
|
|
const definition = { id: { type: 'INTEGER', primaryKey: true } };
|
|
|
|
await safeCreateTable(queryInterface, 'new_table', definition);
|
|
|
|
expect(queryInterface.createTable).toHaveBeenCalledWith(
|
|
'new_table',
|
|
definition
|
|
);
|
|
});
|
|
|
|
it('should skip when table already exists', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['existing_table']);
|
|
|
|
await safeCreateTable(queryInterface, 'existing_table', {});
|
|
|
|
expect(queryInterface.createTable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should re-throw errors from createTable', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue([]);
|
|
queryInterface.createTable.mockRejectedValue(
|
|
new Error('Create failed')
|
|
);
|
|
|
|
await expect(
|
|
safeCreateTable(queryInterface, 'fail_table', {})
|
|
).rejects.toThrow('Create failed');
|
|
});
|
|
});
|
|
|
|
describe('safeAddIndex', () => {
|
|
it('should skip when table does not exist', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue([]);
|
|
await safeAddIndex(queryInterface, 'missing_table', ['col1']);
|
|
expect(queryInterface.addIndex).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should add index when it does not exist', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.showIndex.mockResolvedValue([]);
|
|
|
|
await safeAddIndex(queryInterface, 'tasks', ['user_id'], {
|
|
name: 'idx_user',
|
|
});
|
|
|
|
expect(queryInterface.addIndex).toHaveBeenCalledWith(
|
|
'tasks',
|
|
['user_id'],
|
|
{ name: 'idx_user' }
|
|
);
|
|
});
|
|
|
|
it('should skip when a matching index already exists', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.showIndex.mockResolvedValue([
|
|
{ fields: [{ attribute: 'user_id' }] },
|
|
]);
|
|
|
|
await safeAddIndex(queryInterface, 'tasks', ['user_id']);
|
|
|
|
expect(queryInterface.addIndex).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not throw on addIndex failure (swallows error)', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.showIndex.mockResolvedValue([]);
|
|
queryInterface.addIndex.mockRejectedValue(new Error('Index error'));
|
|
|
|
// safeAddIndex swallows errors (no throw in the catch)
|
|
await expect(
|
|
safeAddIndex(queryInterface, 'tasks', ['col'])
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('safeRemoveColumn', () => {
|
|
it('should skip when column does not exist', async () => {
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {},
|
|
title: {},
|
|
});
|
|
|
|
await safeRemoveColumn(queryInterface, 'tasks', 'nonexistent');
|
|
|
|
expect(queryInterface.removeColumn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should remove column on non-SQLite dialect', async () => {
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {},
|
|
title: {},
|
|
old_col: {},
|
|
});
|
|
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
|
|
|
await safeRemoveColumn(queryInterface, 'tasks', 'old_col');
|
|
|
|
expect(queryInterface.removeColumn).toHaveBeenCalledWith(
|
|
'tasks',
|
|
'old_col'
|
|
);
|
|
});
|
|
|
|
it('should use table recreation strategy on SQLite', async () => {
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {
|
|
type: 'INTEGER',
|
|
primaryKey: true,
|
|
autoIncrement: true,
|
|
allowNull: false,
|
|
},
|
|
title: { type: 'TEXT', allowNull: true },
|
|
remove_me: { type: 'TEXT', allowNull: true },
|
|
});
|
|
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
|
|
|
|
await safeRemoveColumn(queryInterface, 'tasks', 'remove_me');
|
|
|
|
// Should have called multiple queries for SQLite recreation
|
|
const calls = queryInterface.sequelize.query.mock.calls.map(
|
|
(c) => c[0]
|
|
);
|
|
expect(
|
|
calls.some((q) => q.includes('PRAGMA foreign_keys = OFF'))
|
|
).toBe(true);
|
|
expect(
|
|
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
|
|
).toBe(true);
|
|
expect(calls.some((q) => q.includes('INSERT INTO tasks_new'))).toBe(
|
|
true
|
|
);
|
|
expect(calls.some((q) => q.includes('DROP TABLE tasks'))).toBe(
|
|
true
|
|
);
|
|
expect(
|
|
calls.some((q) =>
|
|
q.includes('ALTER TABLE tasks_new RENAME TO tasks')
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
calls.some((q) => q.includes('PRAGMA foreign_keys = ON'))
|
|
).toBe(true);
|
|
// remove_me should not appear in CREATE TABLE
|
|
const createCall = calls.find((q) =>
|
|
q.includes('CREATE TABLE tasks_new')
|
|
);
|
|
expect(createCall).not.toContain('remove_me');
|
|
});
|
|
|
|
it('should re-throw errors', async () => {
|
|
queryInterface.describeTable.mockRejectedValue(
|
|
new Error('Describe failed')
|
|
);
|
|
|
|
await expect(
|
|
safeRemoveColumn(queryInterface, 'tasks', 'col')
|
|
).rejects.toThrow('Describe failed');
|
|
});
|
|
});
|
|
|
|
describe('safeChangeColumn', () => {
|
|
it('should skip when table does not exist', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue([]);
|
|
|
|
await safeChangeColumn(queryInterface, 'missing', 'col', {
|
|
type: 'TEXT',
|
|
});
|
|
|
|
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip when column does not exist', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({ id: {} });
|
|
|
|
await safeChangeColumn(queryInterface, 'tasks', 'nonexistent', {
|
|
type: 'TEXT',
|
|
});
|
|
|
|
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should change column on non-SQLite dialect', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {},
|
|
title: { type: 'TEXT', allowNull: true },
|
|
});
|
|
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
|
const newDef = { type: 'VARCHAR(255)', allowNull: false };
|
|
|
|
await safeChangeColumn(queryInterface, 'tasks', 'title', newDef);
|
|
|
|
expect(queryInterface.changeColumn).toHaveBeenCalledWith(
|
|
'tasks',
|
|
'title',
|
|
newDef
|
|
);
|
|
});
|
|
|
|
it('should use table recreation on SQLite', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({
|
|
id: {
|
|
type: 'INTEGER',
|
|
primaryKey: true,
|
|
autoIncrement: true,
|
|
allowNull: false,
|
|
},
|
|
title: { type: 'TEXT', allowNull: true },
|
|
});
|
|
queryInterface.showIndex.mockResolvedValue([]);
|
|
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
|
|
|
|
await safeChangeColumn(queryInterface, 'tasks', 'title', {
|
|
type: { toSql: () => 'VARCHAR(255)' },
|
|
allowNull: false,
|
|
});
|
|
|
|
const calls = queryInterface.sequelize.query.mock.calls.map(
|
|
(c) => c[0]
|
|
);
|
|
expect(
|
|
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
|
|
).toBe(true);
|
|
const createCall = calls.find((q) =>
|
|
q.includes('CREATE TABLE tasks_new')
|
|
);
|
|
expect(createCall).toContain('VARCHAR(255)');
|
|
expect(createCall).toContain('NOT NULL');
|
|
});
|
|
|
|
it('should re-throw errors', async () => {
|
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
|
queryInterface.describeTable.mockResolvedValue({ id: {}, col: {} });
|
|
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
|
queryInterface.changeColumn.mockRejectedValue(
|
|
new Error('Change failed')
|
|
);
|
|
|
|
await expect(
|
|
safeChangeColumn(queryInterface, 'tasks', 'col', {
|
|
type: 'TEXT',
|
|
})
|
|
).rejects.toThrow('Change failed');
|
|
});
|
|
});
|
|
});
|