-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]>
- Loading branch information
Showing
2 changed files
with
366 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
}) |