mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
feat: Add friends list
This commit is contained in:
parent
6e5fe1ba66
commit
64e03ab7a4
|
@ -1,11 +1,52 @@
|
|||
import request from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import _ from "lodash";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as AuthUtils from "../../../src/utils/auth";
|
||||
import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier";
|
||||
import MonkeyError from "../../../src/utils/error";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
||||
describe("user controller test", () => {
|
||||
const uid = "123456";
|
||||
|
||||
const mockDecodedToken: DecodedIdToken = {
|
||||
uid,
|
||||
email: "newuser@mail.com",
|
||||
iat: 0,
|
||||
} as DecodedIdToken;
|
||||
|
||||
jest.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken);
|
||||
|
||||
function dummyUser(uid): MonkeyTypes.User {
|
||||
return {
|
||||
uid,
|
||||
addedAt: 0,
|
||||
email: "test@example.com",
|
||||
name: "Bob",
|
||||
personalBests: {
|
||||
time: {},
|
||||
words: {},
|
||||
quote: {},
|
||||
custom: {},
|
||||
zen: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const userGetMock = jest.spyOn(UserDal, "getUser");
|
||||
const userGetFriendsListMock = jest.spyOn(UserDal, "getFriendsList");
|
||||
const userAddFriendMock = jest.spyOn(UserDal, "addFriend");
|
||||
const userRemoveFriendMock = jest.spyOn(UserDal, "removeFriend");
|
||||
|
||||
describe("UserController", () => {
|
||||
describe("user creation flow", () => {
|
||||
beforeEach(() => {
|
||||
enableSignUpFeatures(true);
|
||||
});
|
||||
|
||||
it("should be able to check name, sign up, and get user data", async () => {
|
||||
await mockApp
|
||||
.get("/users/checkName/NewUser")
|
||||
|
@ -21,39 +62,6 @@ describe("user controller test", () => {
|
|||
captcha: "captcha",
|
||||
};
|
||||
|
||||
jest.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue({
|
||||
//if stuff breaks this might be the reason
|
||||
users: {
|
||||
signUp: true,
|
||||
discordIntegration: {
|
||||
enabled: false,
|
||||
},
|
||||
autoBan: {
|
||||
enabled: false,
|
||||
maxCount: 5,
|
||||
maxHours: 1,
|
||||
},
|
||||
profiles: {
|
||||
enabled: false,
|
||||
},
|
||||
xp: {
|
||||
enabled: false,
|
||||
gainMultiplier: 0,
|
||||
maxDailyBonus: 0,
|
||||
minDailyBonus: 0,
|
||||
streak: {
|
||||
enabled: false,
|
||||
maxStreakDays: 0,
|
||||
maxStreakMultiplier: 0,
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
enabled: false,
|
||||
maxMail: 0,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await mockApp
|
||||
.post("/users/signup")
|
||||
.set("authorization", "Uid 123456789|newuser@mail.com")
|
||||
|
@ -86,8 +94,201 @@ describe("user controller test", () => {
|
|||
Accept: "application/json",
|
||||
})
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
|
||||
jest.restoreAllMocks();
|
||||
describe("friends", () => {
|
||||
beforeEach(async () => {
|
||||
await enablePremiumFeatures(true);
|
||||
});
|
||||
|
||||
describe("getFriends", () => {
|
||||
afterEach(() => {
|
||||
[userGetMock, userGetFriendsListMock].forEach((it) => it.mockReset());
|
||||
});
|
||||
|
||||
it("should get get friends list", async () => {
|
||||
//GIVEN
|
||||
userGetFriendsListMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
const {
|
||||
body: { data: friendsList },
|
||||
} = await mockApp
|
||||
.get("/users/friends")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(friendsList).toEqual([]);
|
||||
|
||||
expect(userGetFriendsListMock).toBeCalledWith(uid);
|
||||
});
|
||||
|
||||
describe("validations", () => {
|
||||
it("should fail if premium feature is disabled", async () => {
|
||||
//GIVEN
|
||||
await enablePremiumFeatures(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/users/friends")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send()
|
||||
.expect(503)
|
||||
.expect(expectErrorMessage("Premium is temporarily disabled."));
|
||||
});
|
||||
|
||||
it("should fail without authorization", async () => {
|
||||
await mockApp.get("/users/friends").send().expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("addFriend", () => {
|
||||
afterEach(() => {
|
||||
[userGetMock, userAddFriendMock].forEach((it) => it.mockReset());
|
||||
});
|
||||
it("should add friend if exist", async () => {
|
||||
//GIVEN
|
||||
userGetMock.mockResolvedValue(dummyUser("any"));
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.post("/users/friends")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send({ uid: "123" })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(userGetMock).toHaveBeenCalledWith("123", "addFriend");
|
||||
expect(userGetMock).toHaveBeenCalledWith(uid, "addFriend");
|
||||
expect(userAddFriendMock).toHaveBeenCalledWith(uid, "123");
|
||||
});
|
||||
|
||||
it("should fail adding a friend that does not exists", async () => {
|
||||
//GIVEN
|
||||
userGetMock.mockImplementation(async (uid, stack) => {
|
||||
if (uid === "unknown") throw new MonkeyError(404);
|
||||
return dummyUser(uid);
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.post("/users/friends")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send({ uid: "unknown" })
|
||||
.expect(404);
|
||||
|
||||
//THEN
|
||||
expect(userAddFriendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fail exceeding max friends limit", async () => {
|
||||
//GIVEN
|
||||
const user = dummyUser(uid);
|
||||
user.friends = [...Array(32).keys()].map((it) => "uid" + it);
|
||||
userGetMock.mockResolvedValue(user);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.post("/users/friends")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send({ uid: "123" })
|
||||
.expect(400)
|
||||
.expect(expectErrorMessage("You can only have up to 25 friends"));
|
||||
|
||||
//THEN
|
||||
expect(userAddFriendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("validations", () => {
|
||||
it("should fail without body", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.post("/users/friends")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send()
|
||||
.expect(422)
|
||||
.expect(expectErrorMessage('"uid" is required (undefined)'));
|
||||
});
|
||||
|
||||
it("should fail if premium feature is disabled", async () => {
|
||||
//GIVEN
|
||||
await enablePremiumFeatures(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.post("/users/friends")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send()
|
||||
.expect(503)
|
||||
.expect(expectErrorMessage("Premium is temporarily disabled."));
|
||||
});
|
||||
|
||||
it("should fail without authorization", async () => {
|
||||
await mockApp.post("/users/friends").send().expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeFriend", () => {
|
||||
afterEach(() => {
|
||||
[userGetMock, userRemoveFriendMock].forEach((it) => it.mockReset());
|
||||
});
|
||||
it("should remove friend if exist", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete("/users/friends/123")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(userRemoveFriendMock).toHaveBeenCalledWith(uid, "123");
|
||||
});
|
||||
describe("validations", () => {
|
||||
it("should fail if premium feature is disabled", async () => {
|
||||
//GIVEN
|
||||
await enablePremiumFeatures(false);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete("/users/friends/123")
|
||||
.set("Authorization", "Bearer 123456789")
|
||||
.send()
|
||||
.expect(503)
|
||||
.expect(expectErrorMessage("Premium is temporarily disabled."));
|
||||
});
|
||||
|
||||
it("should fail without authorization", async () => {
|
||||
await mockApp.delete("/users/friends/123").send().expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function enablePremiumFeatures(premium: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
users: { premium: { enabled: premium }, signup: true },
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(Configuration, "getCachedConfiguration")
|
||||
.mockResolvedValue(mockConfig);
|
||||
}
|
||||
|
||||
async function enableSignUpFeatures(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
users: { signUp: enabled },
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(Configuration, "getCachedConfiguration")
|
||||
.mockResolvedValue(mockConfig);
|
||||
}
|
||||
|
||||
function expectErrorMessage(message: string): (res: request.Response) => void {
|
||||
return (res) => expect(res.body).toHaveProperty("message", message);
|
||||
}
|
||||
|
|
|
@ -781,4 +781,106 @@ describe("UserDal", () => {
|
|||
await expect(streak).toBe(expectedStreak);
|
||||
}
|
||||
});
|
||||
|
||||
describe("deleteUser", () => {
|
||||
it("should remove deleted user from other users friends list", async () => {
|
||||
//GIVEN
|
||||
const user = new ObjectId().toHexString();
|
||||
const otherUser = new ObjectId().toHexString();
|
||||
await UserDAL.addUser("u1", "u1@axample.com", user);
|
||||
await UserDAL.addUser("u2", "test email", otherUser);
|
||||
await UserDAL.addFriend(otherUser, user);
|
||||
await expect(UserDAL.getUser(otherUser, "test")).resolves.toHaveProperty(
|
||||
"friends",
|
||||
[user]
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await UserDAL.deleteUser(user);
|
||||
|
||||
//THEN
|
||||
await expect(UserDAL.getUser(user, "test")).rejects.toThrow(
|
||||
"User not found\nStack: test"
|
||||
);
|
||||
await expect(UserDAL.getUser(otherUser, "test")).resolves.toHaveProperty(
|
||||
"friends",
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
describe("friends", () => {
|
||||
it("should get friend list", async () => {
|
||||
//GIVEN
|
||||
const user = new ObjectId().toHexString();
|
||||
const friend = new ObjectId().toHexString();
|
||||
const friend2 = new ObjectId().toHexString();
|
||||
await UserDAL.addUser("u1", "u1@axample.com", user);
|
||||
await UserDAL.addUser("u2", "test email", friend);
|
||||
await UserDAL.addUser("u3", "test email", friend2);
|
||||
await UserDAL.addFriend(user, friend);
|
||||
await UserDAL.addFriend(user, friend2);
|
||||
await UserDAL.addFriend(user, "unknown");
|
||||
|
||||
//WHEN
|
||||
await expect(UserDAL.getFriendsList(user)).resolves.toEqual([
|
||||
{ uid: friend, name: "u2" },
|
||||
{ uid: friend2, name: "u3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add friend ", async () => {
|
||||
//GIVEN
|
||||
const user = new ObjectId().toHexString();
|
||||
const friend = new ObjectId().toHexString();
|
||||
await UserDAL.addUser("u1", "u1@axample.com", user);
|
||||
await UserDAL.addUser("u2", "test email", friend);
|
||||
|
||||
//WHEN
|
||||
await UserDAL.addFriend(user, friend);
|
||||
|
||||
//THEN
|
||||
await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty(
|
||||
"friends",
|
||||
[friend]
|
||||
);
|
||||
|
||||
//WHEN
|
||||
//adding the same friend twice
|
||||
await UserDAL.addFriend(user, friend);
|
||||
|
||||
//THEN
|
||||
await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty(
|
||||
"friends",
|
||||
[friend]
|
||||
);
|
||||
});
|
||||
it("should remove friend ", async () => {
|
||||
//GIVEN
|
||||
const user = new ObjectId().toHexString();
|
||||
const friend = new ObjectId().toHexString();
|
||||
await UserDAL.addUser("u1", "u1@axample.com", user);
|
||||
await UserDAL.addUser("u2", "test email", friend);
|
||||
await UserDAL.addFriend(user, friend);
|
||||
|
||||
//WHEN
|
||||
await UserDAL.removeFriend(user, friend);
|
||||
|
||||
//THEN
|
||||
await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty(
|
||||
"friends",
|
||||
[]
|
||||
);
|
||||
|
||||
//WHEN
|
||||
//remove an unknown friend
|
||||
await expect(UserDAL.removeFriend(user, "unknown")).resolves;
|
||||
|
||||
//THEN
|
||||
await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty(
|
||||
"friends",
|
||||
[]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -915,3 +915,41 @@ export async function revokeAllTokens(
|
|||
removeTokensFromCacheByUid(uid);
|
||||
return new MonkeyResponse("All tokens revoked");
|
||||
}
|
||||
|
||||
export async function getFriends(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const friends = await UserDAL.getFriendsList(uid);
|
||||
return new MonkeyResponse("Friends retrieved", friends ?? []);
|
||||
}
|
||||
export async function addFriend(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const newFriend = req.body.uid;
|
||||
|
||||
const user = await UserDAL.getUser(uid, "addFriend");
|
||||
//TODO move max friends to config
|
||||
if (user.friends?.length || 0 > 25) {
|
||||
throw new MonkeyError(400, "You can only have up to 25 friends");
|
||||
}
|
||||
|
||||
// To make sure that the friend exists
|
||||
await UserDAL.getUser(newFriend, "addFriend");
|
||||
|
||||
await UserDAL.addFriend(uid, newFriend);
|
||||
return new MonkeyResponse("Friend added");
|
||||
}
|
||||
|
||||
export async function removeFriend(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { friendUid } = req.params;
|
||||
|
||||
await UserDAL.removeFriend(uid, friendUid);
|
||||
|
||||
return new MonkeyResponse("Friend removed");
|
||||
}
|
||||
|
|
|
@ -94,6 +94,13 @@ const languageSchema = joi
|
|||
.required();
|
||||
const quoteIdSchema = joi.string().min(1).max(10).regex(/\d+/).required();
|
||||
|
||||
const validatePremiumFeatureIsEnabled = validateConfiguration({
|
||||
criteria: (configuration) => {
|
||||
return configuration.users.premium.enabled;
|
||||
},
|
||||
invalidMessage: "Premium is temporarily disabled.",
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateRequest(),
|
||||
|
@ -659,4 +666,38 @@ router.post(
|
|||
asyncHandler(UserController.revokeAllTokens)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/friends",
|
||||
validatePremiumFeatureIsEnabled,
|
||||
authenticateRequest(),
|
||||
RateLimit.userFriendsGet,
|
||||
asyncHandler(UserController.getFriends)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/friends",
|
||||
validatePremiumFeatureIsEnabled,
|
||||
authenticateRequest(),
|
||||
RateLimit.userFriendsAdd,
|
||||
validateRequest({
|
||||
body: {
|
||||
uid: joi.string().required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(UserController.addFriend)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/friends/:friendUid",
|
||||
validatePremiumFeatureIsEnabled,
|
||||
authenticateRequest(),
|
||||
RateLimit.userFriendsDelete,
|
||||
validateRequest({
|
||||
params: {
|
||||
friendUid: joi.string().required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(UserController.removeFriend)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -46,6 +46,12 @@ export async function addUser(
|
|||
|
||||
export async function deleteUser(uid: string): Promise<void> {
|
||||
await getUsersCollection().deleteOne({ uid });
|
||||
|
||||
//remove deleted user from other users friends lists
|
||||
await getUsersCollection().updateMany(
|
||||
{ friends: uid },
|
||||
{ $pull: { friends: uid } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetUser(uid: string): Promise<void> {
|
||||
|
@ -1068,3 +1074,41 @@ export async function logIpAddress(
|
|||
}
|
||||
await getUsersCollection().updateOne({ uid }, { $set: { ips: currentIps } });
|
||||
}
|
||||
|
||||
export async function getFriendsList(
|
||||
uid: string
|
||||
): Promise<MonkeyTypes.Friend[]> {
|
||||
const query = getUsersCollection().aggregate([
|
||||
{ $match: { uid } },
|
||||
{
|
||||
$lookup: {
|
||||
from: "users",
|
||||
localField: "friends",
|
||||
foreignField: "uid",
|
||||
as: "friends",
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: { "friends.uid": 1, "friends.name": 1, _id: 0 },
|
||||
},
|
||||
]);
|
||||
const result = await query.toArray();
|
||||
return result ? result[0].friends : {};
|
||||
}
|
||||
|
||||
export async function addFriend(uid: string, newFriend: string): Promise<void> {
|
||||
await getUsersCollection().updateOne(
|
||||
{ uid },
|
||||
{ $addToSet: { friends: newFriend } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeFriend(
|
||||
uid: string,
|
||||
oldFriend: string
|
||||
): Promise<void> {
|
||||
await getUsersCollection().updateOne(
|
||||
{ uid },
|
||||
{ $pull: { friends: oldFriend } }
|
||||
);
|
||||
}
|
||||
|
|
|
@ -542,6 +542,25 @@ export const webhookLimit = rateLimit({
|
|||
handler: customHandler,
|
||||
});
|
||||
|
||||
export const userFriendsGet = rateLimit({
|
||||
windowMs: ONE_HOUR_MS,
|
||||
max: 60 * REQUEST_MULTIPLIER,
|
||||
keyGenerator: getKeyWithUid,
|
||||
handler: customHandler,
|
||||
});
|
||||
export const userFriendsAdd = rateLimit({
|
||||
windowMs: ONE_HOUR_MS,
|
||||
max: 60 * REQUEST_MULTIPLIER,
|
||||
keyGenerator: getKeyWithUid,
|
||||
handler: customHandler,
|
||||
});
|
||||
export const userFriendsDelete = rateLimit({
|
||||
windowMs: ONE_HOUR_MS,
|
||||
max: 60 * REQUEST_MULTIPLIER,
|
||||
keyGenerator: getKeyWithUid,
|
||||
handler: customHandler,
|
||||
});
|
||||
|
||||
export const apeKeysUpdate = apeKeysGenerate;
|
||||
|
||||
export const apeKeysDelete = apeKeysGenerate;
|
||||
|
|
6
backend/src/types/types.d.ts
vendored
6
backend/src/types/types.d.ts
vendored
|
@ -101,6 +101,7 @@ declare namespace MonkeyTypes {
|
|||
lbOptOut?: boolean;
|
||||
premium?: PremiumInfo;
|
||||
ips?: UserIpHistory;
|
||||
friends?: string[]; //List of user uids
|
||||
}
|
||||
|
||||
interface UserStreak {
|
||||
|
@ -399,4 +400,9 @@ declare namespace MonkeyTypes {
|
|||
startTimestamp: number;
|
||||
expirationTimestamp: number;
|
||||
}
|
||||
|
||||
interface Friend {
|
||||
uid: string;
|
||||
name: string;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue