diff --git a/lib/actions/down.js b/lib/actions/down.js index 9bc0492..49d7b0e 100644 --- a/lib/actions/down.js +++ b/lib/actions/down.js @@ -22,6 +22,7 @@ export default async (db, client) => { if (item) { try { const migration = await migrationsDir.loadMigration(item.fileName); + db.migrationFile = item.fileName; await migration.down(db, client); } catch (err) { diff --git a/lib/actions/up.js b/lib/actions/up.js index 405e021..74b0b9e 100644 --- a/lib/actions/up.js +++ b/lib/actions/up.js @@ -22,6 +22,7 @@ export default async (db, client) => { const migrateItem = async item => { try { const migration = await migrationsDir.loadMigration(item.fileName); + db.migrationFile = item.fileName; await migration.up(db, client); } catch (err) { diff --git a/lib/env/autoRollback.js b/lib/env/autoRollback.js new file mode 100644 index 0000000..ddc7e26 --- /dev/null +++ b/lib/env/autoRollback.js @@ -0,0 +1,193 @@ +// Constants +const COLLECTION_INTERCEPTED_METHODS = [ + 'insertOne', + 'insertMany', + 'replaceOne', + 'updateOne', + 'updateMany', + 'deleteOne', + 'deleteMany', +]; + +/** + * Creates inverse operations for auto-rollback functionality + */ +const INVERSE_OPERATIONS = { + async insertOne(collection, filterArg, operationResult) { + if (!operationResult) return []; + return [{ deleteOne: { filter: { _id: operationResult.insertedId } } }]; + }, + async insertMany(collection, filterArg, operationResult) { + if (!operationResult) return []; + return [{ deleteMany: { filter: { _id: { $in: Object.values(operationResult.insertedIds) } } } }]; + }, + async replaceOne(collection, filterArg, operationResult) { + if (operationResult) return []; + const doc = await collection.findOne(filterArg); + + /* istanbul ignore next */ + if (!doc) return []; + + return [{ + replaceOne: { + filter: { _id: doc._id }, + replacement: doc + } + }]; + }, + async updateOne(collection, filterArg, operationResult) { + return this.replaceOne(collection, filterArg, operationResult); + }, + async updateMany(collection, filterArg, operationResult) { + if (operationResult) return []; + + const docs = await collection.find(filterArg).toArray(); + return docs.map(doc => ({ + replaceOne: { + filter: { _id: doc._id }, + replacement: doc + } + })); + }, + async deleteOne(collection, filterArg, operationResult) { + if (operationResult) return []; + const doc = await collection.findOne(filterArg); + + /* istanbul ignore next */ + if (!doc) return []; + + return [{ insertOne: doc }]; + }, + async deleteMany(collection, filterArg, operationResult) { + if (operationResult) return []; + + const docs = await collection.find(filterArg).toArray(); + return docs.map(doc => ({ insertOne: doc })); + } +}; + + +// Creates a wrapped collection method that records inverse operations for rollback +function createWrappedMethod(methodName, collection, originalMethod, db, autoRollbackCollection) { + return async function (...args) { + try { + const filterArg = args[0]; + const preOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, null); + + // Original MongoDb operation + const operationResult = await originalMethod(...args); + + const postOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, operationResult); + + // Combine PreOperation and postRollback operations + const rollbackOperations = [...preOperation, ...postOperation]; + + // Informs the rollback order + db.autoRollbackCounter = db.autoRollbackCounter ?? 0; + + const timestamp = new Date(); + const bulkWriteInsertOperations = rollbackOperations.map(operation => ({ + insertOne: { + timestamp, + migrationFile: db.migrationFile, + orderIndex: db.autoRollbackCounter++, + originalArgs: args, + collection: collection.collectionName, + bulkWriteOperation: operation, + } + })); + + // Write rollback operations to the auto-rollback collection + await autoRollbackCollection.bulkWrite(bulkWriteInsertOperations, { ordered: true }); + + return operationResult; + } catch (error) { + /* istanbul ignore next */ + throw new Error(`Failed to execute ${methodName} with auto-rollback: ${error.message}`); + } + }; +} + +// Wraps a collection to intercept methods for auto-rollback tracking +function wrapDbCollection(collection, db, configContent, excludedCollections) { + const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); + + COLLECTION_INTERCEPTED_METHODS.forEach(methodName => { + const originalMethod = collection[methodName].bind(collection); + collection[methodName] = createWrappedMethod( + methodName, + collection, + originalMethod, + db, + autoRollbackCollection); + }); + + return collection; +} + +export default { + wrapDbWithAutoRollback(db, configContent, originalCollection) { + const autoRollbackExcludedCollections = [ + configContent.changelogCollectionName, + configContent.lockCollectionName, + configContent.autoRollbackCollectionName + ]; + + // Override the collection method to return wrapped collections + db.collection = (name, options) => { + const collection = originalCollection(name, options); + + if (autoRollbackExcludedCollections.includes(collection.collectionName)) { + return collection; + } + + // istanbul ignore next + if (!configContent.autoRollbackCollectionName + || !db.autoRollbackEnabled) { + + if (db.autoRollbackEnabled) { + // Auto-rollback is enabled but not properly configured + throw new Error("Auto-rollback is not enabled in the config file."); + } + return collection; + } + + return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); + }; + + // Performs auto-rollback for the current migration + db.autoRollback = async () => { + + // istanbul ignore next + if (configContent.autoRollbackCollectionName === undefined + || configContent.autoRollbackCollectionName === null) { + configContent.autoRollbackCollectionName = "auto_rollback_migrations"; + } + + try { + const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); + const collectionNames = await autoRollbackCollection.distinct( + "collection", + { migrationFile: db.migrationFile } + ); + + for (const collectionName of collectionNames) { + const targetCollection = originalCollection(collectionName); + const rollbackEntries = await autoRollbackCollection + .find({ migrationFile: db.migrationFile, collection: collectionName }) + .sort({ timestamp: -1, orderIndex: -1 }) + .project({ _id: 0, bulkWriteOperation: 1 }) + .toArray(); + + const operations = rollbackEntries.map(e => e.bulkWriteOperation); + await targetCollection.bulkWrite(operations, { ordered: true }); + } + + await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); + } catch (error) { + /* istanbul ignore next */ + throw new Error(`Auto-rollback failed: ${error.message}`); + } + }; + } +}; \ No newline at end of file diff --git a/lib/env/database.js b/lib/env/database.js index 68c9a67..44bde80 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -1,5 +1,6 @@ import { MongoClient } from "mongodb"; import config from "./config.js"; +import autoRollback from "./autoRollback.js"; export default { async connect() { @@ -18,10 +19,12 @@ export default { ); const db = client.db(databaseName); + const originalCollection = db.collection.bind(db); + autoRollback.wrapDbWithAutoRollback(db, configContent, originalCollection); db.close = client.close; return { client, db, }; } -}; +}; \ No newline at end of file diff --git a/test/env/autoRollback.test.js b/test/env/autoRollback.test.js new file mode 100644 index 0000000..154de7f --- /dev/null +++ b/test/env/autoRollback.test.js @@ -0,0 +1,636 @@ +vi.mock("mongodb"); + +import config from "../../lib/env/config.js"; +import mongodb from "mongodb"; +import database from "../../lib/env/database.js"; + +describe("database - autoRollback feature", () => { + let configObj; + let client; + + function createConfigObj() { + return { + mongodb: { + url: "mongodb://someserver:27017", + databaseName: "testDb", + options: { + connectTimeoutMS: 3600000, // 1 hour + socketTimeoutMS: 3600000 // 1 hour + } + }, + changelogCollectionName: "changelog", + lockCollectionName: "lock", + autoRollbackCollectionName: "autoRollback" + }; + } + + function mockClient() { + // Create a mock collection function + const collectionFunc = function(name) { + return { + the: "db", + collectionName: name + }; + }; + + const mockDb = { + the: "db", + collection: collectionFunc + }; + + return { + db: vi.fn().mockReturnValue(mockDb), + close: "theCloseFnFromMongoClient" + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + configObj = createConfigObj(); + client = mockClient(); + vi.spyOn(config, 'read').mockReturnValue(configObj); + vi.spyOn(mongodb.MongoClient, "connect").mockResolvedValue(client); + }); + + describe("collection method wrapping", () => { + let mockDb; + let mockAutoRollbackCollection; + + beforeEach(() => { + // Function to create a fresh mock collection + function createMockCollection(name) { + return { + collectionName: name, + insertOne: vi.fn().mockResolvedValue({ insertedId: "123" }), + insertMany: vi.fn().mockResolvedValue({ insertedIds: ["1", "2"] }), + updateOne: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + updateMany: vi.fn().mockResolvedValue({ modifiedCount: 2 }), + replaceOne: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + deleteOne: vi.fn().mockResolvedValue({ deletedCount: 1 }), + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 2 }), + findOne: vi.fn().mockResolvedValue({ _id: "doc1", name: "test" }), + find: vi.fn().mockReturnValue({ + toArray: vi.fn().mockResolvedValue([ + { _id: "doc1", name: "test1" }, + { _id: "doc2", name: "test2" } + ]) + }), + distinct: vi.fn().mockResolvedValue([name]) + }; + } + + + mockAutoRollbackCollection = { + collectionName: "autoRollback", + bulkWrite: vi.fn().mockResolvedValue({ insertedCount: 1 }), + distinct: vi.fn().mockResolvedValue(["testCollection"]), + find: vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([]) + }), + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 1 }) + }; + + // Create mock db with originalCollection method + const originalCollectionFunc = function(name) { + if (name === "autoRollback") { + return mockAutoRollbackCollection; + } + // Return a fresh mock collection for each call + return createMockCollection(name); + }; + + mockDb = { + collection: originalCollectionFunc, + close: vi.fn(), + migrationFile: "test-migration.js", + autoRollbackCounter: 0 + }; + + client.db.mockReturnValue(mockDb); + }); + + it("should wrap collection methods when autoRollbackEnabled is true", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + + expect(typeof collection.insertOne).toBe("function"); + expect(typeof collection.insertMany).toBe("function"); + expect(typeof collection.updateOne).toBe("function"); + expect(typeof collection.updateMany).toBe("function"); + expect(typeof collection.replaceOne).toBe("function"); + expect(typeof collection.deleteOne).toBe("function"); + expect(typeof collection.deleteMany).toBe("function"); + }); + + it("should not wrap collection methods when autoRollbackEnabled is false", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = false; + + const collection = result.db.collection("users"); + + // Should return the original mock collection - verify by checking it has the original methods + expect(collection.insertOne).toBeDefined(); + expect(collection.collectionName).toBe("users"); + }); + + it("should store rollback entry when insertOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.insertOne({ name: "John" }); + + // Verify rollback entry was created + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; + + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne).toBeDefined(); + expect(bulkWriteOps[0].insertOne.collection).toBe("users"); + expect(bulkWriteOps[0].insertOne.migrationFile).toBe("test-migration.js"); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ deleteOne: { filter: { _id: "123" } } }); + }); + + it("should store rollback entry when insertMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + const docs = [{ name: "John" }, { name: "Jane" }]; + await collection.insertMany(docs); + + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; + + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ deleteMany: { filter: { _id: { $in: ["1", "2"] } } } }); + expect(bulkWriteOps[0].insertOne.migrationFile).toBe("test-migration.js"); + }); + + it("should store rollback entry when updateOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.updateOne({ name: "John" }, { $set: { age: 30 } }); + + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; + + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test" } + } + }); + }); + + it("should store rollback entries when updateMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.updateMany({ age: { $gt: 20 } }, { $set: { active: true } }); + + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; + + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test1" } + } + }); + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).toEqual({ + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "test2" } + } + }); + }); + + it("should store rollback entry when deleteOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.deleteOne({ name: "John" }); + + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; + + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ + insertOne: { _id: "doc1", name: "test" } + }); + }); + + it("should store rollback entries when deleteMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + const docsToDelete = [{ name: "John" }, { name: "Jane" }]; + await collection.deleteMany(docsToDelete); + + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; + + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ + insertOne: { _id: "doc1", name: "test1" } + }); + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).toEqual({ + insertOne: { _id: "doc2", name: "test2" } + }); + }); + + it("should store rollback entries when replaceOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.replaceOne({ name: "John" }, { name: "John", age: 40 }); + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; + + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test" } + } + }); + }); + + it("should increment autoRollbackCounter for each operation", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + + await collection.insertOne({ name: "John" }); + await collection.insertOne({ name: "Jane" }); + await collection.insertOne({ name: "Bob" }); + + expect(mockAutoRollbackCollection.bulkWrite.mock.calls.length).toBe(3); + + const entry1 = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0][0].insertOne; + const entry2 = mockAutoRollbackCollection.bulkWrite.mock.calls[1][0][0].insertOne; + const entry3 = mockAutoRollbackCollection.bulkWrite.mock.calls[2][0][0].insertOne; + + expect(entry1.orderIndex).toBe(0); + expect(entry2.orderIndex).toBe(1); + expect(entry3.orderIndex).toBe(2); + }); + }); + + describe("db.autoRollback()", () => { + let mockDb; + let mockCollection; + let mockAutoRollbackCollection; + + beforeEach(() => { + mockCollection = { + insertOne: vi.fn().mockResolvedValue({ insertedId: "123" }), + insertMany: vi.fn().mockResolvedValue({ insertedIds: ["1", "2"] }), + replaceOne: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + deleteOne: vi.fn().mockResolvedValue({ deletedCount: 1 }), + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 2 }), + bulkWrite: vi.fn().mockResolvedValue({ insertedCount: 1 }) + }; + + mockAutoRollbackCollection = { + distinct: vi.fn().mockResolvedValue([]), + find: vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([]) + }), + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 1 }) + }; + + mockDb = { + collection: vi.fn().mockImplementation((name) => { + if (name === "autoRollback") { + return mockAutoRollbackCollection; + } + return mockCollection; + }), + migrationFile: "test-migration.js" + }; + + // Reset client to have close method + client = { + db: vi.fn().mockReturnValue(mockDb), + close: vi.fn() + }; + + vi.spyOn(mongodb.MongoClient, "connect").mockResolvedValue(client); + }); + + it("should fetch rollback entries for the current migration file", async () => { + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockAutoRollbackCollection.distinct).toHaveBeenCalledOnce(); + expect(mockAutoRollbackCollection.distinct.mock.calls[0][0]).toBe("collection"); + expect(mockAutoRollbackCollection.distinct.mock.calls[0][1]).toEqual({ + migrationFile: "test-migration.js" + }); + }); + + it("should execute insertOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } + }]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ + insertOne: { _id: "doc1", name: "John" } + }]); + }); + + it("should execute insertMany rollback operation", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [ + { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } }, + { bulkWriteOperation: { insertOne: { _id: "doc2", name: "Jane" } } } + ]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([ + { insertOne: { _id: "doc1", name: "John" } }, + { insertOne: { _id: "doc2", name: "Jane" } } + ]); + }); + + it("should execute replaceOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John", age: 25 } + } + } + }]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John", age: 25 } + } + }]); + }); + + it("should execute updateOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + } + }]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + }]); + }); + + it("should execute updateMany rollback operation", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [ + { + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + } + }, + { + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "Jane" } + } + } + } + ]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toHaveLength(2); + expect(operations[0]).toEqual({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + }); + expect(operations[1]).toEqual({ + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "Jane" } + } + }); + }); + + it("should execute deleteOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { deleteOne: { filter: { name: "John" } } } + }]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ + deleteOne: { filter: { name: "John" } } + }]); + }); + + it("should execute deleteMany rollback operation", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } } + }]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ + deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } + }]); + }); + + it("should clean up rollback entries after successful rollback", async () => { + const rollbackEntries = [{ + operation: "insertOne", + collection: "users", + parameters: { _id: "doc1", name: "John" } + }]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockAutoRollbackCollection.deleteMany).toHaveBeenCalledOnce(); + expect(mockAutoRollbackCollection.deleteMany.mock.calls[0][0]).toEqual({ + migrationFile: "test-migration.js" + }); + }); + + it("should execute multiple rollback operations in reverse order", async () => { + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); + + const rollbackEntries = [ + { bulkWriteOperation: { insertOne: { _id: "doc3", name: "Bob" } } }, + { bulkWriteOperation: { deleteOne: { filter: { name: "Jane" } } } }, + { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } } + ]; + + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) + }); + + const result = await database.connect(); + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + // Should execute all operations in one bulkWrite + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toHaveLength(3); + }); + }); +}); diff --git a/test/env/database.test.js b/test/env/database.test.js index 63e012c..1ef6316 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -1,5 +1,10 @@ vi.mock("mongodb"); vi.mock("fs/promises"); +vi.mock("../../lib/env/autoRollback.js", () => ({ + default: { + wrapDbWithAutoRollback: vi.fn() + } +})); import config from "../../lib/env/config.js"; import mongodb from "mongodb"; @@ -23,8 +28,21 @@ describe("database", () => { } function mockClient() { + // Create a mock collection function + const collectionFunc = function(name) { + return { + the: "db", + collectionName: name + }; + }; + + const mockDb = { + the: "db", + collection: collectionFunc + }; + return { - db: vi.fn().mockReturnValue({ the: "db" }), + db: vi.fn().mockReturnValue(mockDb), close: "theCloseFnFromMongoClient" }; } @@ -51,10 +69,10 @@ describe("database", () => { ); expect(client.db).toHaveBeenCalledWith("testDb"); - expect(result.db).toEqual({ - the: "db", - close: "theCloseFnFromMongoClient" - }); + expect(result.db).toBeDefined(); + expect(result.db.the).toBe("db"); + expect(result.db.close).toBe("theCloseFnFromMongoClient"); + expect(typeof result.db.collection).toBe("function"); expect(result.client).toEqual(client); }); @@ -68,4 +86,4 @@ describe("database", () => { await expect(database.connect()).rejects.toThrow("Unable to connect"); }); }); -}); +}); \ No newline at end of file