Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions backend/__tests__/__integration__/dal/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1271,22 +1271,26 @@ describe("UserDal", () => {
describe("linkDiscord", () => {
it("throws for nonexisting user", async () => {
await expect(async () =>
UserDAL.linkDiscord("unknown", "", ""),
UserDAL.linkDiscord("unknown", "", "", {}),
).rejects.toThrow("User not found\nStack: link discord");
});
it("should update", async () => {
//given
const { uid } = await UserTestData.createUser({
discordId: "discordId",
discordAvatar: "discordAvatar",
challenges: {
"100hours": {},
},
});
//when
await UserDAL.linkDiscord(uid, "newId", "newAvatar");
await UserDAL.linkDiscord(uid, "newId", "newAvatar", { "250hours": {} });

//then
const read = await UserDAL.getUser(uid, "read");
expect(read.discordId).toEqual("newId");
expect(read.discordAvatar).toEqual("newAvatar");
expect(read.challenges).toEqual({ "250hours": {} });
});
it("should update without avatar", async () => {
//given
Expand Down Expand Up @@ -1315,6 +1319,10 @@ describe("UserDal", () => {
const { uid } = await UserTestData.createUser({
discordId: "discordId",
discordAvatar: "discordAvatar",
challenges: {
"100hours": {},
"250hours": { addedAt: Date.now() },
},
});

//when
Expand All @@ -1324,6 +1332,7 @@ describe("UserDal", () => {
const read = await UserDAL.getUser(uid, "read");
expect(read.discordId).toBeUndefined();
expect(read.discordAvatar).toBeUndefined();
expect(read.challenges).toBeUndefined();
});
});
describe("updateInbox", () => {
Expand Down
41 changes: 36 additions & 5 deletions backend/__tests__/api/controllers/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboar
import * as ConnectionsDal from "../../../src/dal/connections";
import { pb } from "../../__testData__/users";
import Test from "supertest/lib/test";
import { getChallenge } from "@monkeytype/challenges";

const { mockApp, uid, mockAuth } = setup();
const configuration = Configuration.getCachedConfiguration();
Expand Down Expand Up @@ -1552,7 +1553,7 @@ describe("user controller test", () => {
it("should get oauth link", async () => {
//WHEN
const { body } = await mockApp
.get("/users/discord/oauth")
.get("/users/discord/oauth?includeRoles=true")
.set("Authorization", `Bearer ${uid}`)
.expect(200);

Expand All @@ -1561,7 +1562,9 @@ describe("user controller test", () => {
message: "Discord oauth link generated",
data: { url },
});
expect(getOauthLinkMock).toHaveBeenCalledWith(uid);
expect(getOauthLinkMock).toHaveBeenCalledWith(uid, {
includeRoles: true,
});
});
it("should fail if feature is not enabled", async () => {
//GIVEN
Expand All @@ -1587,6 +1590,7 @@ describe("user controller test", () => {
"iStateValidForUser",
);
const getDiscordUserMock = vi.spyOn(DiscordUtils, "getDiscordUser");
const getDiscordRoleIdsMock = vi.spyOn(DiscordUtils, "getDiscordRoleIds");
const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains");
const userLinkDiscordMock = vi.spyOn(UserDal, "linkDiscord");
const georgeLinkDiscordMock = vi.spyOn(GeorgeQueue, "linkDiscord");
Expand All @@ -1599,6 +1603,9 @@ describe("user controller test", () => {
id: "discordUserId",
avatar: "discordUserAvatar",
});
getDiscordRoleIdsMock.mockResolvedValue([
getChallenge("100hours").discordRoleId,
]);
isDiscordIdAvailableMock.mockResolvedValue(true);
blocklistContainsMock.mockResolvedValue(false);
userLinkDiscordMock.mockResolvedValue();
Expand All @@ -1610,6 +1617,7 @@ describe("user controller test", () => {
isStateValidForUserMock,
isDiscordIdAvailableMock,
getDiscordUserMock,
getDiscordRoleIdsMock,
blocklistContainsMock,
userLinkDiscordMock,
georgeLinkDiscordMock,
Expand All @@ -1629,6 +1637,7 @@ describe("user controller test", () => {
tokenType: "tokenType",
accessToken: "accessToken",
state: "statestatestatestate",
scope: ["scopeOne", "scopeTwo"],
})
.expect(200);

Expand All @@ -1653,6 +1662,11 @@ describe("user controller test", () => {
"tokenType",
"accessToken",
);
expect(getDiscordRoleIdsMock).toHaveBeenCalledWith(
"tokenType",
"accessToken",
["scopeOne", "scopeTwo"],
);
expect(isDiscordIdAvailableMock).toHaveBeenCalledWith("discordUserId");
expect(blocklistContainsMock).toHaveBeenCalledWith({
discordId: "discordUserId",
Expand All @@ -1661,6 +1675,9 @@ describe("user controller test", () => {
uid,
"discordUserId",
"discordUserAvatar",
{
"100hours": {},
},
);
expect(georgeLinkDiscordMock).toHaveBeenCalledWith(
"discordUserId",
Expand Down Expand Up @@ -2962,6 +2979,9 @@ describe("user controller test", () => {
testActivity: {
"2024": fillYearWithDay(94),
},
challenges: {
"100hours": {},
},
};

beforeEach(async () => {
Expand Down Expand Up @@ -3033,12 +3053,15 @@ describe("user controller test", () => {
expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile");
expect(getUserMock).not.toHaveBeenCalled();
});
it("should get testActivity if enabled", async () => {
it("should get testActivity/challenges if enabled", async () => {
//GIVEN
vi.useFakeTimers().setSystemTime(1712102400000);
getUserByNameMock.mockResolvedValue({
...foundUser,
profileDetails: { showActivityOnPublicProfile: true },
profileDetails: {
showActivityOnPublicProfile: true,
showChallengesOnPublicProfile: true,
},
} as any);
const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry;
leaderboardGetRankMock.mockResolvedValue(rank);
Expand All @@ -3054,13 +3077,18 @@ describe("user controller test", () => {
testsByDays: expect.arrayContaining([]),
}),
);

expect(body.data.challenges).toEqual({ "100hours": {} });
});
it("should not get testActivity if disabled", async () => {
//GIVEN
vi.useFakeTimers().setSystemTime(1712102400000);
getUserByNameMock.mockResolvedValue({
...foundUser,
profileDetails: { showActivityOnPublicProfile: false },
profileDetails: {
showActivityOnPublicProfile: false,
showChallengesOnPublicProfile: false,
},
} as any);
const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry;
leaderboardGetRankMock.mockResolvedValue(rank);
Expand All @@ -3071,6 +3099,7 @@ describe("user controller test", () => {

//THEN
expect(body.data.testActivity).toBeUndefined();
expect(body.data.challenges).toBeUndefined();
});

it("should get base profile for banned user", async () => {
Expand Down Expand Up @@ -3188,6 +3217,7 @@ describe("user controller test", () => {
website: "https://monkeytype.com",
},
showActivityOnPublicProfile: false,
showChallengesOnPublicProfile: false,
};

//WHEN
Expand Down Expand Up @@ -3216,6 +3246,7 @@ describe("user controller test", () => {
website: "https://monkeytype.com",
},
showActivityOnPublicProfile: false,
showChallengesOnPublicProfile: false,
},
{
badges: [{ id: 4 }, { id: 2, selected: true }, { id: 3 }],
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
},
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/challenges": "workspace:*",
"@monkeytype/contracts": "workspace:*",
"@monkeytype/funbox": "workspace:*",
"@monkeytype/schemas": "workspace:*",
Expand Down
42 changes: 38 additions & 4 deletions backend/src/api/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
CountByYearAndDay,
TestActivity,
UserProfileDetails,
UserChallenges,
} from "@monkeytype/schemas/users";
import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs";
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
Expand All @@ -59,6 +60,7 @@ import {
ForgotPasswordEmailRequest,
GetCurrentTestActivityResponse,
GetCustomThemesResponse,
GetDiscordOauthLinkQuery,
GetDiscordOauthLinkResponse,
GetFavoriteQuotesResponse,
GetFriendsResponse,
Expand Down Expand Up @@ -94,6 +96,15 @@ import { tryCatch } from "@monkeytype/util/trycatch";
import * as ConnectionsDal from "../../dal/connections";
import { PersonalBest } from "@monkeytype/schemas/shared";

import { ChallengeName } from "@monkeytype/schemas/challenges";
import { getChallenges } from "@monkeytype/challenges";

const challengeNameByRoleId: Record<string, ChallengeName> = Object.fromEntries(
getChallenges()
.filter((it) => it.discordRoleId !== undefined)
.map((it) => [it.discordRoleId, it.name]),
);

async function verifyCaptcha(captcha: string): Promise<void> {
const { data: verified, error } = await tryCatch(verify(captcha));
if (error) {
Expand Down Expand Up @@ -629,12 +640,13 @@ export async function getUser(req: MonkeyRequest): Promise<GetUserResponse> {
}

export async function getOauthLink(
req: MonkeyRequest,
req: MonkeyRequest<GetDiscordOauthLinkQuery>,
): Promise<GetDiscordOauthLinkResponse> {
const { uid } = req.ctx.decodedToken;
const { includeRoles } = req.query;

//build the url
const url = await DiscordUtils.getOauthLink(uid);
const url = await DiscordUtils.getOauthLink(uid, { includeRoles });

//return
return new MonkeyResponse("Discord oauth link generated", {
Expand All @@ -646,7 +658,7 @@ export async function linkDiscord(
req: MonkeyRequest<undefined, LinkDiscordRequest>,
): Promise<LinkDiscordResponse> {
const { uid } = req.ctx.decodedToken;
const { tokenType, accessToken, state } = req.body;
const { tokenType, accessToken, state, scope } = req.body;

if (!(await DiscordUtils.iStateValidForUser(state, uid))) {
throw new MonkeyError(403, "Invalid user token");
Expand Down Expand Up @@ -692,7 +704,20 @@ export async function linkDiscord(
throw new MonkeyError(409, "The Discord account is blocked");
}

await UserDAL.linkDiscord(uid, discordId, discordAvatar);
let roles = await DiscordUtils.getDiscordRoleIds(
tokenType,
accessToken,
scope,
);

const challenges: UserChallenges = Object.fromEntries(
roles
.map((roleId) => challengeNameByRoleId[roleId])
.filter((it) => it !== undefined)
.map((it) => [it, {}]),
);

await UserDAL.linkDiscord(uid, discordId, discordAvatar, challenges);

await GeorgeQueue.linkDiscord(discordId, uid, userInfo.lbOptOut ?? false);
void addImportantLog("user_discord_link", `linked to ${discordId}`, uid);
Expand Down Expand Up @@ -1006,6 +1031,13 @@ export async function getProfile(
} else {
delete profileData.testActivity;
}

if (user.profileDetails?.showChallengesOnPublicProfile) {
profileData.challenges = user.challenges;
} else {
delete profileData.challenges;
}

return new MonkeyResponse("Profile retrieved", profileData);
}

Expand All @@ -1019,6 +1051,7 @@ export async function updateProfile(
socialProfiles,
selectedBadgeId,
showActivityOnPublicProfile,
showChallengesOnPublicProfile,
} = req.body;

const user = await UserDAL.getPartialUser(uid, "update user profile", [
Expand Down Expand Up @@ -1048,6 +1081,7 @@ export async function updateProfile(
]),
),
showActivityOnPublicProfile,
showChallengesOnPublicProfile,
};

await UserDAL.updateProfile(uid, profileDetailsUpdates, user.inventory);
Expand Down
43 changes: 5 additions & 38 deletions backend/src/constants/auto-roles.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,5 @@
export default [
"oneHourWarrior",
"doubleDown",
"tripleTrouble",
"quad",
"trueSimp",
"bigramSalad",
"simp",
"antidiseWhat",
"whatsThisWebsiteCalledAgain",
"developd",
"slowAndSteady",
"speedSpacer",
"iveGotThePower",
"accuracyExpert",
"accuracyMaster",
"accuracyGod",
"jolly",
"gottaCatchEmAll",
"rapGod",
"navySeal",
"rollercoaster",
"oneHourMirror",
"chooChoo",
"earfquake",
"simonSez",
"accountant",
"hidden",
"iCanSeeTheFuture",
"whatAreWordsAtThisPoint",
"specials",
"aeiou",
"asciiWarrior",
"iKiNdAlIkEhOwInEfFiCiEnTqWeRtYiS",
"oneNauseousMonkey",
"69",
"englishMaster",
];
import { getChallenges } from "@monkeytype/challenges";

export default getChallenges()
.filter((it) => it.autoRole)
.map((it) => it.name);
7 changes: 6 additions & 1 deletion backend/src/dal/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
User,
CountByYearAndDay,
Friend,
UserChallenges,
} from "@monkeytype/schemas/users";
import {
Mode,
Expand Down Expand Up @@ -613,19 +614,23 @@ export async function linkDiscord(
uid: string,
discordId: string,
discordAvatar?: string,
challenges?: UserChallenges,
): Promise<void> {
const updates: Partial<DBUser> = { discordId };
if (discordAvatar !== undefined && discordAvatar !== null) {
updates.discordAvatar = discordAvatar;
}
if (challenges !== undefined) {
updates.challenges = challenges;
}

await updateUser({ uid }, { $set: updates }, { stack: "link discord" });
}

export async function unlinkDiscord(uid: string): Promise<void> {
await updateUser(
{ uid },
{ $unset: { discordId: "", discordAvatar: "" } },
{ $unset: { discordId: "", discordAvatar: "", challenges: "" } },
{ stack: "unlink discord" },
);
}
Expand Down
Loading
Loading