feat: Add friends list

This commit is contained in:
Christian Fehmer 2023-12-06 19:37:33 +01:00
parent 6e5fe1ba66
commit 64e03ab7a4
No known key found for this signature in database
GPG key ID: 7294582286D5F1F4
7 changed files with 486 additions and 35 deletions

View file

@ -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);
}

View file

@ -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",
[]
);
});
});
});
});

View file

@ -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");
}

View file

@ -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;

View file

@ -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 } }
);
}

View file

@ -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;

View file

@ -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;
}
}