Skip to content

Commit

Permalink
Change the request format for social media routes (#423)
Browse files Browse the repository at this point in the history
* changed some formats

* check edge case of duplicate items

* edited some tests

* lint fix

* package bump
  • Loading branch information
dowhep authored Apr 1, 2024
1 parent 90b8f98 commit 5a48e05
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 80 deletions.
11 changes: 5 additions & 6 deletions api/controllers/UserController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,15 @@ export class UserController {
@AuthenticatedUser() user: UserModel): Promise<InsertSocialMediaResponse> {
const userSocialMedia = await this.userSocialMediaService
.insertSocialMediaForUser(user, insertSocialMediaRequest.socialMedia);
return { error: null, userSocialMedia: userSocialMedia.getPublicSocialMedia() };
return { error: null, userSocialMedia: userSocialMedia.map((socialMedia) => socialMedia.getPublicSocialMedia()) };
}

@Patch('/socialMedia/:uuid')
async updateSocialMediaForUser(@Params() params: UuidParam,
@Body() updateSocialMediaRequest: UpdateSocialMediaRequest,
@Patch('/socialMedia')
async updateSocialMediaForUser(@Body() updateSocialMediaRequest: UpdateSocialMediaRequest,
@AuthenticatedUser() user: UserModel): Promise<UpdateSocialMediaResponse> {
const userSocialMedia = await this.userSocialMediaService
.updateSocialMediaByUuid(user, params.uuid, updateSocialMediaRequest.socialMedia);
return { error: null, userSocialMedia: userSocialMedia.getPublicSocialMedia() };
.updateSocialMediaByUuid(user, updateSocialMediaRequest.socialMedia);
return { error: null, userSocialMedia: userSocialMedia.map((socialMedia) => socialMedia.getPublicSocialMedia()) };
}

@Delete('/socialMedia/:uuid')
Expand Down
11 changes: 8 additions & 3 deletions api/validators/UserSocialMediaControllerRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,25 @@ export class SocialMedia implements ISocialMedia {
}

export class SocialMediaPatches implements ISocialMediaPatches {
@IsDefined()
@IsNotEmpty()
url?: string;
uuid: string;

@IsDefined()
@IsNotEmpty()
url: string;
}

export class InsertSocialMediaRequest implements IInsertUserSocialMediaRequest {
@Type(() => SocialMedia)
@ValidateNested()
@IsDefined()
socialMedia: SocialMedia;
socialMedia: SocialMedia[];
}

export class UpdateSocialMediaRequest implements IUpdateUserSocialMediaRequest {
@Type(() => SocialMediaPatches)
@ValidateNested()
@IsDefined()
socialMedia: SocialMediaPatches;
socialMedia: SocialMediaPatches[];
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@acmucsd/membership-portal",
"version": "3.5.0",
"version": "3.5.1",
"description": "REST API for ACM UCSD's membership portal.",
"main": "index.d.ts",
"files": [
Expand Down
59 changes: 44 additions & 15 deletions services/UserSocialMediaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { UserError } from '../utils/Errors';
import { UserSocialMediaModel } from '../models/UserSocialMediaModel';
import { UserModel } from '../models/UserModel';
import Repositories, { TransactionsManager } from '../repositories';
import { Uuid, SocialMedia } from '../types';
import { Uuid, SocialMedia, SocialMediaPatches } from '../types';

@Service()
export default class UserSocialMediaService {
Expand All @@ -22,29 +22,58 @@ export default class UserSocialMediaService {
return userSocialMedia;
}

public async insertSocialMediaForUser(user: UserModel, socialMedia: SocialMedia) {
public async insertSocialMediaForUser(user: UserModel, socialMedias: SocialMedia[]) {
const addedSocialMedia = await this.transactions.readWrite(async (txn) => {
// checking duplicate
const setDuplicateType = new Set();
socialMedias.forEach((socialMedia) => {
const { type } = socialMedia;
if (setDuplicateType.has(type)) {
throw new UserError(`Dupllicate type "${type}" found in the request`);
}
setDuplicateType.add(type);
});

// inserting social media
const userSocialMediaRepository = Repositories.userSocialMedia(txn);
const isNewSocialMediaType = await userSocialMediaRepository.isNewSocialMediaTypeForUser(user, socialMedia.type);
if (!isNewSocialMediaType) {
throw new UserError('Social media URL of this type has already been created for this user');
}
return userSocialMediaRepository.upsertSocialMedia(UserSocialMediaModel.create({ ...socialMedia, user }));
const upsertedSocialMedias = await Promise.all(socialMedias.map(async (socialMedia) => {
const isNewSocialMediaType = await userSocialMediaRepository
.isNewSocialMediaTypeForUser(user, socialMedia.type);
if (!isNewSocialMediaType) {
throw new UserError(`Social media URL of type "${socialMedia.type}" has already been created for this user`);
}
return userSocialMediaRepository.upsertSocialMedia(UserSocialMediaModel.create({ ...socialMedia, user }));
}));
return upsertedSocialMedias;
});
return addedSocialMedia;
}

public async updateSocialMediaByUuid(user: UserModel,
uuid: Uuid,
changes: Partial<UserSocialMediaModel>): Promise<UserSocialMediaModel> {
changes: SocialMediaPatches[]): Promise<UserSocialMediaModel[]> {
const updatedSocialMedia = await this.transactions.readWrite(async (txn) => {
// checking duplicate
const setDuplicateUuid = new Set();

changes.forEach((socialMediaPatch) => {
const { uuid } = socialMediaPatch;
if (setDuplicateUuid.has(uuid)) {
throw new UserError(`Dupllicate UUID "${uuid}" found in the request`);
}
setDuplicateUuid.add(uuid);
});

// patching social media
const userSocialMediaRepository = Repositories.userSocialMedia(txn);
const socialMedia = await userSocialMediaRepository.findByUuid(uuid);
if (!socialMedia) throw new NotFoundError('Social media URL not found');
if (user.uuid !== socialMedia.user.uuid) {
throw new ForbiddenError('User cannot update a social media URL of another user');
}
return userSocialMediaRepository.upsertSocialMedia(socialMedia, changes);
const modifiedSocialMedia = await Promise.all(changes.map(async (socialMediaPatches) => {
const socialMedia = await userSocialMediaRepository.findByUuid(socialMediaPatches.uuid);
if (!socialMedia) throw new NotFoundError(`Social media of UUID "${socialMediaPatches.uuid}" not found`);
if (user.uuid !== socialMedia.user.uuid) {
throw new ForbiddenError('User cannot update a social media URL of another user');
}
return userSocialMediaRepository.upsertSocialMedia(socialMedia, { url: socialMediaPatches.url });
}));
return modifiedSocialMedia;
});
return updatedSocialMedia;
}
Expand Down
129 changes: 79 additions & 50 deletions tests/userSocialMedia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,31 @@ describe('social media URL submission', () => {

test('properly persists on successful submission', async () => {
const conn = await DatabaseConnection.get();
let member = UserFactory.fake();
const userSocialMedia = UserSocialMediaFactory.fake({ user: member });
let member1 = UserFactory.fake();
const socialMediaTypes = [SocialMediaType.FACEBOOK, SocialMediaType.TWITTER, SocialMediaType.LINKEDIN];
const userSocialMediaList = socialMediaTypes.map((type) => UserSocialMediaFactory.fake({ user: member1, type }));
// sort by type to ensure order is consistent
userSocialMediaList.sort((a, b) => a.type.localeCompare(b.type));

// console.log("LIST", userSocialMediaList);

await new PortalState()
.createUsers(member)
.createUsers(member1)
.write();

const userController = ControllerFactory.user(conn);
await userController.insertSocialMediaForUser({ socialMedia: userSocialMedia }, member);
member = await conn.manager.findOne(UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] });

expect(member.userSocialMedia).toHaveLength(1);
expect(member.userSocialMedia[0]).toEqual({
url: userSocialMedia.url,
type: userSocialMedia.type,
uuid: userSocialMedia.uuid,
await userController.insertSocialMediaForUser({ socialMedia: userSocialMediaList }, member1);
member1 = await conn.manager.findOne(UserModel, { uuid: member1.uuid }, { relations: ['userSocialMedia'] });

expect(member1.userSocialMedia).toHaveLength(3);
const userSocialMediaQuery = member1.userSocialMedia.sort((a, b) => a.type.localeCompare(b.type));
userSocialMediaList.forEach((socialMedia, index) => {
expect(userSocialMediaQuery[index]).toEqual({
url: socialMedia.url,
type: socialMedia.type,
uuid: socialMedia.uuid,
});
});
});

Expand All @@ -55,8 +64,9 @@ describe('social media URL submission', () => {
const userController = ControllerFactory.user(conn);

const userSocialMediaWithSameType = UserSocialMediaFactory.fake({ type: SocialMediaType.FACEBOOK });
const errorMessage = 'Social media URL of this type has already been created for this user';
await expect(userController.insertSocialMediaForUser({ socialMedia: userSocialMediaWithSameType }, member))
const errorMessage = `Social media URL of type "${SocialMediaType.FACEBOOK}"`
+ ' has already been created for this user';
await expect(userController.insertSocialMediaForUser({ socialMedia: [userSocialMediaWithSameType] }, member))
.rejects.toThrow(errorMessage);
});
});
Expand All @@ -68,22 +78,29 @@ describe('social media URL update', () => {

test('is invalidated when updating social media URL of another user', async () => {
const conn = await DatabaseConnection.get();
let member = UserFactory.fake();
let member1 = UserFactory.fake();
const unauthorizedMember = UserFactory.fake();
const userSocialMedia = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.FACEBOOK });
const socialMediaTypes = [SocialMediaType.FACEBOOK, SocialMediaType.TWITTER, SocialMediaType.LINKEDIN];
const userSocialMediaList = socialMediaTypes.map((type) => UserSocialMediaFactory.fake({ user: member1, type }));
// sort by type to ensure order is consistent
userSocialMediaList.sort((a, b) => a.type.localeCompare(b.type));

await new PortalState()
.createUsers(member, unauthorizedMember)
.createUserSocialMedia(member, userSocialMedia)
.createUsers(member1, unauthorizedMember)
.write();

const userController = ControllerFactory.user(conn);
member = await conn.manager.findOne(UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] });

await userController.insertSocialMediaForUser({ socialMedia: userSocialMediaList }, member1);
member1 = await conn.manager.findOne(UserModel, { uuid: member1.uuid }, { relations: ['userSocialMedia'] });

expect(member1.userSocialMedia).toHaveLength(3);
const errorMessage = 'User cannot update a social media URL of another user';
const uuidParams = { uuid: member.userSocialMedia[0].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await expect(userController.updateSocialMediaForUser(uuidParams, socialMediaParams, unauthorizedMember))
const socialMediaParams = { socialMedia: [{
url: faker.internet.url(),
uuid: member1.userSocialMedia[0].uuid,
}] };
await expect(userController.updateSocialMediaForUser(socialMediaParams, unauthorizedMember))
.rejects.toThrow(errorMessage);
});

Expand All @@ -97,10 +114,12 @@ describe('social media URL update', () => {

const userController = ControllerFactory.user(conn);

const errorMessage = 'Social media URL not found';
const missingEntityUuid = { uuid: faker.datatype.uuid() };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await expect(userController.updateSocialMediaForUser(missingEntityUuid, socialMediaParams, member))
const socialMediaParams = { socialMedia: [{
url: faker.internet.url(),
uuid: faker.datatype.uuid(),
}] };
const errorMessage = `Social media of UUID "${socialMediaParams.socialMedia[0].uuid}" not found`;
await expect(userController.updateSocialMediaForUser(socialMediaParams, member))
.rejects.toThrow(errorMessage);
});
});
Expand Down Expand Up @@ -130,7 +149,7 @@ describe('social media URL delete', () => {
});
});

describe('social media URL update', () => {
describe('social media URL update concurrency', () => {
let conn : Connection;
let member : UserModel;
let userController : UserController;
Expand Down Expand Up @@ -183,14 +202,16 @@ describe('social media URL update', () => {
test.concurrent('concurrent updates properly persist on successful submission 0', async () => {
await waitForFlag();
const index = 0;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);
const socialMediaParams = { socialMedia: [{
url: faker.internet.url(),
uuid: member.userSocialMedia[index].uuid,
}] };
await userController.updateSocialMediaForUser(socialMediaParams, member);

const expectedUserSocialMedia0 = {
url: socialMediaParams.socialMedia.url,
url: socialMediaParams.socialMedia[0].url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
uuid: socialMediaParams.socialMedia[0].uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
Expand All @@ -207,14 +228,16 @@ describe('social media URL update', () => {
test.concurrent('concurrent updates properly persist on successful submission 1', async () => {
await waitForFlag();
const index = 1;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);
const socialMediaParams = { socialMedia: [{
url: faker.internet.url(),
uuid: member.userSocialMedia[index].uuid,
}] };
await userController.updateSocialMediaForUser(socialMediaParams, member);

const expectedUserSocialMedia1 = {
url: socialMediaParams.socialMedia.url,
url: socialMediaParams.socialMedia[0].url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
uuid: socialMediaParams.socialMedia[0].uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
Expand All @@ -231,14 +254,16 @@ describe('social media URL update', () => {
test.concurrent('concurrent updates properly persist on successful submission 2', async () => {
await waitForFlag();
const index = 2;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);
const socialMediaParams = { socialMedia: [{
url: faker.internet.url(),
uuid: member.userSocialMedia[index].uuid,
}] };
await userController.updateSocialMediaForUser(socialMediaParams, member);

const expectedUserSocialMedia2 = {
url: socialMediaParams.socialMedia.url,
url: socialMediaParams.socialMedia[0].url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
uuid: socialMediaParams.socialMedia[0].uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
Expand All @@ -255,14 +280,16 @@ describe('social media URL update', () => {
test.concurrent('concurrent updates properly persist on successful submission 3', async () => {
await waitForFlag();
const index = 3;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);
const socialMediaParams = { socialMedia: [{
url: faker.internet.url(),
uuid: member.userSocialMedia[index].uuid,
}] };
await userController.updateSocialMediaForUser(socialMediaParams, member);

const expectedUserSocialMedia3 = {
url: socialMediaParams.socialMedia.url,
url: socialMediaParams.socialMedia[0].url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
uuid: socialMediaParams.socialMedia[0].uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
Expand All @@ -279,14 +306,16 @@ describe('social media URL update', () => {
test.concurrent('concurrent updates properly persist on successful submission 4', async () => {
await waitForFlag();
const index = 4;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);
const socialMediaParams = { socialMedia: [{
url: faker.internet.url(),
uuid: member.userSocialMedia[index].uuid,
}] };
await userController.updateSocialMediaForUser(socialMediaParams, member);

const expectedUserSocialMedia4 = {
url: socialMediaParams.socialMedia.url,
url: socialMediaParams.socialMedia[0].url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
uuid: socialMediaParams.socialMedia[0].uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
Expand Down
7 changes: 4 additions & 3 deletions types/ApiRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,16 @@ export interface FeedbackSearchOptions {
}

export interface InsertUserSocialMediaRequest {
socialMedia: SocialMedia;
socialMedia: SocialMedia[];
}

export interface SocialMediaPatches {
url?: string;
uuid: string;
url: string;
}

export interface UpdateUserSocialMediaRequest {
socialMedia: SocialMediaPatches;
socialMedia: SocialMediaPatches[];
}

// LEADERBOARD
Expand Down
Loading

0 comments on commit 5a48e05

Please sign in to comment.