From eec8564ba8df6cadf4621127fd2eb673236b0033 Mon Sep 17 00:00:00 2001 From: Jacob Capps <99674188+jcbcapps@users.noreply.github.com> Date: Thu, 22 Jun 2023 15:11:51 -0500 Subject: [PATCH] fix: Migration to remove duplicate users (#1040) * Add migration to remove duplicate users * Update to handle FeaturedShortcuts and GuardianIdeal * Make userId a unique field * Remove comment * Update test to include custom collection --------- Co-authored-by: John Gedeon --- .../1686078533740-remove-duplicate-users.js | 55 ++++ .../migrations/remove-duplicate-users.test.js | 311 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 migrations/1686078533740-remove-duplicate-users.js create mode 100644 test/migrations/remove-duplicate-users.test.js diff --git a/migrations/1686078533740-remove-duplicate-users.js b/migrations/1686078533740-remove-duplicate-users.js new file mode 100644 index 000000000..cfc580069 --- /dev/null +++ b/migrations/1686078533740-remove-duplicate-users.js @@ -0,0 +1,55 @@ +'use strict' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { runMigration } = require('../utils/mongodb') + +module.exports.up = runMigration(async (db) => { + const users = await db.collection('users').find({}).toArray() + const userIds = users.map((user) => user.userId) + const uniqueUserIds = [...new Set(userIds)] + + for (const userId of uniqueUserIds) { + const usersWithSameUserId = users.filter((user) => user.userId === userId) + if (usersWithSameUserId.length > 1) { + const firstUser = usersWithSameUserId[0] + const otherUsers = usersWithSameUserId.slice(1) + + // Merge the mySpace arrays, only keeping items of type 'Collection' from duplicate users + const mergedMySpace = firstUser.mySpace.concat( + ...otherUsers.map((user) => + user.mySpace.filter((item) => item.type === 'Collection') + ) + ) + + // Check if any of the duplicate users have a theme with the value 'dark' + const hasDarkTheme = otherUsers.some((user) => user.theme === 'dark') + + // Delete the duplicate users based off of their userId and _id + await db.collection('users').deleteMany({ + $or: otherUsers.map((user) => ({ + userId: user.userId, + _id: user._id, + })), + }) + + // Update the first user + await db.collection('users').updateOne( + { userId: firstUser.userId }, + { + $set: { + mySpace: mergedMySpace, + theme: hasDarkTheme ? 'dark' : firstUser.theme, + }, + } + ) + } + } + + // Make the userId field unique + await db.collection('users').createIndex({ userId: 1 }, { unique: true }) +}) + +module.exports.down = (next) => { + // Do nothing. We don't need to put the duplicate users back. + next() +} diff --git a/test/migrations/remove-duplicate-users.test.js b/test/migrations/remove-duplicate-users.test.js new file mode 100644 index 000000000..80cb1ef2e --- /dev/null +++ b/test/migrations/remove-duplicate-users.test.js @@ -0,0 +1,311 @@ +import { ObjectId } from 'mongodb' + +import { connectDb } from '../../utils/mongodb' + +import { up, down } from '../../migrations/1686078533740-remove-duplicate-users' + +const TESTUSER1 = 'user1' +const TEST_ACCOUNT = [ + { + _id: ObjectId(), + userId: TESTUSER1, + mySpace: [ + { + _id: ObjectId(), + title: 'Featured Shortcuts', + type: 'FeaturedShortcuts', + }, + { + _id: ObjectId(), + title: 'Guardian Ideal', + type: 'GuardianIdeal', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u58s1835ql974leo1yll', + title: 'Empty Collection', + type: 'Collection', + bookmarks: [], + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u58s1835ql974leo1yll', + title: 'Collection One', + type: 'Collection', + bookmarks: [ + { + _id: ObjectId(), + cmsId: 'cktd7c0d30190w597qoftevq1', + url: 'https://afpcsecure.us.af.mil/', + label: 'vMPF', + }, + ], + }, + ], + displayName: 'USER ONE', + theme: 'light', + }, +] + +const TEST_ACCOUNT_COPY = [ + { + _id: ObjectId(), + userId: TESTUSER1, + mySpace: [ + { + _id: ObjectId(), + title: 'Featured Shortcuts', + type: 'FeaturedShortcuts', + }, + { + _id: ObjectId(), + title: 'Guardian Ideal', + type: 'GuardianIdeal', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u58s1835ql974leo1yll', + title: 'Example Collection', + type: 'Collection', + bookmarks: [ + { + _id: ObjectId(), + cmsId: 'cktd7c0d30190w597qoftevq1', + url: 'https://afpcsecure.us.af.mil/', + label: 'vMPF', + }, + { + _id: ObjectId(), + cmsId: 'cktd7ettn0457w597p7ja4uye', + url: 'https://leave.af.mil/profile', + label: 'LeaveWeb', + }, + { + _id: ObjectId(), + cmsId: 'cktd7hjz30636w5977vu4la4c', + url: 'https://mypay.dfas.mil/#/', + label: 'MyPay', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3tphw1763ql97pia1zkvc', + url: 'https://webmail.apps.mil/', + label: 'Webmail', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u4461813ql970wkd254m', + url: 'https://www.e-publishing.af.mil/', + label: 'e-Publications', + }, + ], + }, + ], + displayName: 'USER COPY', + theme: 'dark', + }, +] + +const ANOTHER_TEST_ACCOUNT_COPY = [ + { + _id: ObjectId(), + userId: TESTUSER1, + mySpace: [ + { + _id: ObjectId(), + title: 'Featured Shortcuts', + type: 'FeaturedShortcuts', + }, + { + _id: ObjectId(), + title: 'Guardian Ideal', + type: 'GuardianIdeal', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u58s1835ql974leo1yll', + title: 'Example Collection', + type: 'Collection', + bookmarks: [ + { + _id: ObjectId(), + cmsId: 'cktd7c0d30190w597qoftevq1', + url: 'https://afpcsecure.us.af.mil/', + label: 'vMPF', + }, + { + _id: ObjectId(), + cmsId: 'cktd7ettn0457w597p7ja4uye', + url: 'https://leave.af.mil/profile', + label: 'LeaveWeb', + }, + { + _id: ObjectId(), + cmsId: 'cktd7hjz30636w5977vu4la4c', + url: 'https://mypay.dfas.mil/#/', + label: 'MyPay', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3tphw1763ql97pia1zkvc', + url: 'https://webmail.apps.mil/', + label: 'Webmail', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u4461813ql970wkd254m', + url: 'https://www.e-publishing.af.mil/', + label: 'e-Publications', + }, + ], + }, + { + _id: ObjectId(), + title: 'Custom Collection', + type: 'Collection', + bookmarks: [ + { + _id: ObjectId(), + cmsId: 'cktd7c0d30190w597qoftevq1', + url: 'https://afpcsecure.us.af.mil/', + label: 'vMPF', + }, + { + _id: ObjectId(), + cmsId: 'cktd7ettn0457w597p7ja4uye', + url: 'https://leave.af.mil/profile', + label: 'LeaveWeb', + }, + { + _id: ObjectId(), + cmsId: 'cktd7hjz30636w5977vu4la4c', + url: 'https://mypay.dfas.mil/#/', + label: 'MyPay', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3tphw1763ql97pia1zkvc', + url: 'https://webmail.apps.mil/', + label: 'Webmail', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u4461813ql970wkd254m', + url: 'https://www.e-publishing.af.mil/', + label: 'e-Publications', + }, + ], + }, + ], + displayName: 'ANOTHER USER COPY', + theme: 'light', + }, +] + +const UNINVOLVED_USER = [ + { + _id: ObjectId(), + userId: 'anotherUser', + mySpace: [ + { + _id: ObjectId(), + title: 'Featured Shortcuts', + type: 'FeaturedShortcuts', + }, + { + _id: ObjectId(), + title: 'Guardian Ideal', + type: 'GuardianIdeal', + }, + { + _id: ObjectId(), + cmsId: 'ckwz3u58s1835ql974leo1yll', + title: 'Example Collection', + type: 'Collection', + bookmarks: [ + { + _id: ObjectId(), + cmsId: 'ckwz3u4461813ql970wkd254m', + url: 'https://www.e-publishing.af.mil/', + label: 'e-Publications', + }, + ], + }, + ], + displayName: 'UNINVOLVED USER', + theme: 'dark', + }, +] + +describe('[Migration: Remove Duplicate Users]', () => { + let connection + let db + + beforeAll(async () => { + // This is NOT the connection used in the migration itself + // Just use to seed data for the test + const mongoConnection = await connectDb() + connection = mongoConnection.connection + db = mongoConnection.db + + // Reset db + await db.collection('users').deleteMany({}) + + // Seed db with duplicate users + await db.collection('users').insertMany(TEST_ACCOUNT) + await db.collection('users').insertMany(TEST_ACCOUNT_COPY) + await db.collection('users').insertMany(ANOTHER_TEST_ACCOUNT_COPY) + await db.collection('users').insertMany(UNINVOLVED_USER) + }) + + afterAll(async () => { + await connection.close() + }) + + test('up', async () => { + // Find the duplicate users + let users = await db.collection('users').find({ userId: TESTUSER1 }) + users = await users.toArray() + expect(users).toHaveLength(3) + + // Check that both users have the same userId + expect(users[0].userId).toEqual(users[1].userId) + + // Remove the duplicate users + await up() + + // Find the remaining user + users = await db.collection('users').find({ userId: TESTUSER1 }) + users = await users.toArray() + expect(users).toHaveLength(1) + + // Remaining user should have the merged mySpace + expect(users[0].mySpace).toHaveLength(7) + expect(users[0].displayName).toEqual('USER ONE') + expect(users[0].theme).toEqual('dark') + expect( + users[0].mySpace.filter((item) => item.type === 'FeaturedShortcuts') + ).toHaveLength(1) + expect( + users[0].mySpace.filter((item) => item.type === 'GuardianIdeal') + ).toHaveLength(1) + + // Throw an error if there is an attempt to create a new user with the same userId as an existing user + await expect( + db.collection('users').insertOne({ + userId: TESTUSER1, + mySpace: [], + displayName: 'I SHOULD NOT WORK', + theme: 'light', + }) + ).rejects.toThrow() + }) + + test('down', async () => { + const downMock = jest.fn() + + down(downMock) + + expect(downMock).toHaveBeenCalledTimes(1) + }) +})