Skip to content

Commit

Permalink
fix: Migration to remove duplicate users (#1040)
Browse files Browse the repository at this point in the history
* 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
jcbcapps and gidjin authored Jun 22, 2023
1 parent 7b21d50 commit eec8564
Show file tree
Hide file tree
Showing 2 changed files with 366 additions and 0 deletions.
55 changes: 55 additions & 0 deletions migrations/1686078533740-remove-duplicate-users.js
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()
}
311 changes: 311 additions & 0 deletions test/migrations/remove-duplicate-users.test.js
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)
})
})

0 comments on commit eec8564

Please sign in to comment.