diff --git a/backend/__tests__/__integration__/dal/friends.spec.ts b/backend/__tests__/__integration__/dal/friends.spec.ts new file mode 100644 index 000000000000..248d6d2b9ca2 --- /dev/null +++ b/backend/__tests__/__integration__/dal/friends.spec.ts @@ -0,0 +1,448 @@ +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from "vitest"; +import { ObjectId } from "mongodb"; + +import * as FriendsDal from "../../../src/dal/friends"; +import { createUser, pb } from "../../__testData__/users"; + +describe("FriendsDal", () => { + beforeAll(async () => { + await FriendsDal.createIndicies(); + }); + + describe("getRequests", () => { + it("get by uid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initOne = await createFriend({ initiatorUid: uid }); + const initTwo = await createFriend({ initiatorUid: uid }); + const friendOne = await createFriend({ friendUid: uid }); + const _decoy = await createFriend({}); + + //WHEN / THEM + + expect( + await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid }) + ).toStrictEqual([initOne, initTwo, friendOne]); + }); + + it("get by uid and status", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initAccepted = await createFriend({ + initiatorUid: uid, + status: "accepted", + }); + const _initPending = await createFriend({ + initiatorUid: uid, + status: "pending", + }); + const initBlocked = await createFriend({ + initiatorUid: uid, + status: "blocked", + }); + + const friendAccepted = await createFriend({ + friendUid: uid, + status: "accepted", + }); + const _friendPending = await createFriend({ + friendUid: uid, + status: "pending", + }); + + const _decoy = await createFriend({ status: "accepted" }); + + //WHEN / THEN + + expect( + await FriendsDal.getRequests({ + initiatorUid: uid, + friendUid: uid, + status: ["accepted", "blocked"], + }) + ).toStrictEqual([initAccepted, initBlocked, friendAccepted]); + }); + }); + + describe("create", () => { + const now = 1715082588; + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("should fail creating duplicates", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + + //WHEN/THEN + await expect( + createFriend({ + initiatorUid: first.friendUid, + friendUid: uid, + }) + ).rejects.toThrow("Duplicate friend or blocked"); + }); + + it("should create", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const friendUid = new ObjectId().toHexString(); + + //WHEN + const created = await FriendsDal.create( + { uid, name: "Bob" }, + { uid: friendUid, name: "Kevin" }, + 2 + ); + + //THEN + expect(created).toEqual({ + _id: created._id, + initiatorUid: uid, + initiatorName: "Bob", + friendUid: friendUid, + friendName: "Kevin", + addedAt: now, + status: "pending", + key: `${uid}/${friendUid}`, + }); + }); + + it("should fail if maximum friends are reached", async () => { + //GIVEN + const initiatorUid = new ObjectId().toHexString(); + await createFriend({ initiatorUid }); + await createFriend({ initiatorUid }); + + //WHEN / THEM + await expect(createFriend({ initiatorUid }, 2)).rejects.toThrow( + "Maximum number of friends reached\nStack: create friend request" + ); + }); + }); + describe("updateStatus", () => { + it("should update the status", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + friendUid: uid, + }); + const second = await createFriend({ + friendUid: uid, + }); + + //WHEN + await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted"); + + //THEN + expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual([ + { ...first, status: "accepted" }, + second, + ]); + + //can update twice to the same status + await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted"); + }); + it("should fail if uid does not match the friendUid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + + //WHEN / THEN + await expect( + FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted") + ).rejects.toThrow("Friend not found"); + }); + }); + + describe("deleteById", () => { + it("should delete by initiator", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + const second = await createFriend({ + initiatorUid: uid, + }); + + //WHEN + await FriendsDal.deleteById(uid, first._id.toHexString()); + + //THEN + expect(await FriendsDal.getRequests({ initiatorUid: uid })).toStrictEqual( + [second] + ); + }); + + it("should delete by friend", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + friendUid: uid, + }); + const second = await createFriend({ + friendUid: uid, + status: "accepted", + }); + + //WHEN + await FriendsDal.deleteById(uid, first._id.toHexString()); + + //THEN + expect( + await FriendsDal.getRequests({ initiatorUid: second.initiatorUid }) + ).toStrictEqual([second]); + }); + + it("should fail if uid does not match", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + + //WHEN / THEN + await expect( + FriendsDal.deleteById("Bob", first._id.toHexString()) + ).rejects.toThrow("Cannot be deleted"); + }); + + it("should fail if initiator deletes blocked by friend", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const myRequestWasBlocked = await createFriend({ + initiatorName: uid, + status: "blocked", + }); + + //WHEN / THEN + await expect( + FriendsDal.deleteById(uid, myRequestWasBlocked._id.toHexString()) + ).rejects.toThrow("Cannot be deleted"); + }); + it("allow friend to delete blocked", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const myBlockedUser = await createFriend({ + friendUid: uid, + status: "blocked", + }); + + //WHEN + await FriendsDal.deleteById(uid, myBlockedUser._id.toHexString()); + + //THEN + expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual([]); + }); + }); + + describe("deleteByUid", () => { + it("should delete by uid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const _initOne = await createFriend({ initiatorUid: uid }); + const _initTwo = await createFriend({ initiatorUid: uid }); + const _friendOne = await createFriend({ friendUid: uid }); + const decoy = await createFriend({}); + + //WHEN + await FriendsDal.deleteByUid(uid); + + //THEN + expect( + await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid }) + ).toEqual([]); + + expect( + await FriendsDal.getRequests({ initiatorUid: decoy.initiatorUid }) + ).toEqual([decoy]); + }); + }); + describe("updateName", () => { + it("should update the name", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initOne = await createFriend({ + initiatorUid: uid, + initiatorName: "Bob", + }); + const initTwo = await createFriend({ + initiatorUid: uid, + initiatorName: "Bob", + }); + const friendOne = await createFriend({ + friendUid: uid, + friendName: "Bob", + }); + const decoy = await createFriend({}); + + //WHEN + await FriendsDal.updateName(uid, "King Bob"); + + //THEN + expect( + await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid }) + ).toEqual([ + { ...initOne, initiatorName: "King Bob" }, + { ...initTwo, initiatorName: "King Bob" }, + { ...friendOne, friendName: "King Bob" }, + ]); + + expect( + await FriendsDal.getRequests({ initiatorUid: decoy.initiatorUid }) + ).toEqual([decoy]); + }); + }); + + describe("getFriends", () => { + it("get list of friends", async () => { + //GIVEN + + const me = await createUser({ name: "Me" }); + const uid = me.uid; + + const friendOne = await createUser({ + name: "One", + personalBests: { + time: { "15": [pb(100)], "60": [pb(85), pb(90)] }, + } as any, + inventory: { + badges: [{ id: 42, selected: true }, { id: 23 }, { id: 5 }], + }, + banned: true, + lbOptOut: true, + premium: { expirationTimestamp: -1 } as any, + }); + const friendOneRequest = await createFriend({ + initiatorUid: uid, + friendUid: friendOne.uid, + status: "accepted", + addedAt: 100, + }); + const friendTwo = await createUser({ + name: "Two", + discordId: "discordId", + discordAvatar: "discordAvatar", + timeTyping: 600, + startedTests: 150, + completedTests: 125, + streak: { + length: 10, + maxLength: 50, + } as any, + xp: 42, + inventory: { + badges: [{ id: 23 }, { id: 5 }], + }, + premium: { expirationTimestamp: Date.now() + 5000 } as any, + }); + const friendTwoRequest = await createFriend({ + initiatorUid: uid, + friendUid: friendTwo.uid, + status: "accepted", + addedAt: 200, + }); + + const friendThree = await createUser({ name: "Three" }); + const friendThreeRequest = await createFriend({ + friendUid: uid, + initiatorUid: friendThree.uid, + status: "accepted", + addedAt: 300, + }); + + //non accepted + await createFriend({ friendUid: uid, status: "pending" }); + await createFriend({ initiatorUid: uid, status: "blocked" }); + + //WHEN + const friends = await FriendsDal.getFriends(uid); + + //THEN + expect(friends).toEqual([ + { + uid: friendOne.uid, + name: "One", + addedAt: 100, + friendRequestId: friendOneRequest._id, + // oxlint-disable-next-line no-non-null-assertion + top15: friendOne.personalBests.time["15"]![0] as any, + // oxlint-disable-next-line no-non-null-assertion + top60: friendOne.personalBests.time["60"]![1] as any, + badgeId: 42, + banned: true, + lbOptOut: true, + isPremium: true, + }, + { + uid: friendTwo.uid, + name: "Two", + addedAt: 200, + friendRequestId: friendTwoRequest._id, + discordId: friendTwo.discordId, + discordAvatar: friendTwo.discordAvatar, + timeTyping: friendTwo.timeTyping, + startedTests: friendTwo.startedTests, + completedTests: friendTwo.completedTests, + streak: friendTwo.streak, + xp: friendTwo.xp, + isPremium: true, + }, + { + uid: friendThree.uid, + name: "Three", + addedAt: 300, + friendRequestId: friendThreeRequest._id, + }, + { + uid: me.uid, + name: "Me", + }, + ]); + + expect((await FriendsDal.getFriendsUids(uid)).sort()).toEqual( + [me.uid, friendOne.uid, friendTwo.uid, friendThree.uid].sort() + ); + }); + }); +}); + +async function createFriend( + data: Partial, + maxFriendsPerUser = 25 +): Promise { + const result = await FriendsDal.create( + { + uid: data.initiatorUid ?? new ObjectId().toHexString(), + name: data.initiatorName ?? "user" + new ObjectId().toHexString(), + }, + { + uid: data.friendUid ?? new ObjectId().toHexString(), + name: data.friendName ?? "user" + new ObjectId().toHexString(), + }, + maxFriendsPerUser + ); + await FriendsDal.getCollection().updateOne( + { _id: result._id }, + { $set: data } + ); + return { ...result, ...data }; +} diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index afe64f556787..233340f34de6 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -191,7 +191,7 @@ describe("LeaderboardsDal", () => { //WHEN await LeaderboardsDal.update("time", "15", "english"); - const result = (await LeaderboardsDal.get( + const results = (await LeaderboardsDal.get( "time", "15", "english", @@ -200,7 +200,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => _.omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noBadge }), @@ -229,7 +229,7 @@ describe("LeaderboardsDal", () => { //WHEN await LeaderboardsDal.update("time", "15", "english"); - const result = (await LeaderboardsDal.get( + const results = (await LeaderboardsDal.get( "time", "15", "english", @@ -239,7 +239,7 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - const lb = result.map((it) => _.omit(it, ["_id"])); + const lb = results.map((it) => _.omit(it, ["_id"])); expect(lb).toEqual([ expectedLbEntry("15", { rank: 1, user: noPremium }), @@ -262,7 +262,7 @@ describe("LeaderboardsDal", () => { //WHEN await LeaderboardsDal.update("time", "15", "english"); - const result = (await LeaderboardsDal.get( + const results = (await LeaderboardsDal.get( "time", "15", "english", @@ -272,14 +272,168 @@ describe("LeaderboardsDal", () => { )) as DBLeaderboardEntry[]; //THEN - expect(result[0]?.isPremium).toBeUndefined(); + expect(results[0]?.isPremium).toBeUndefined(); + }); + }); + describe("get", () => { + it("should get for page", async () => { + //GIVEN + const _rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2))); + const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const rank3 = await createUser(lbBests(undefined, pb(95, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 1, + 2, + true + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = results.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 3, user: rank3 }), + expectedLbEntry("60", { rank: 4, user: rank4 }), + ]); + }); + it("should get for friends only", async () => { + //GIVEN + const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2))); + const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const _rank3 = await createUser(lbBests(undefined, pb(100, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 0, + 50, + false, + [rank1.uid, rank4.uid] + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = results.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 1, user: rank1, friendsRank: 1 }), + expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 2 }), + ]); + }); + it("should get for friends only with page", async () => { + //GIVEN + const rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2))); + const rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const _rank3 = await createUser(lbBests(undefined, pb(95, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 1, + 2, + false, + [rank1.uid, rank2.uid, rank4.uid] + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = results.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 3 }), + ]); + }); + it("should return empty list if no friends", async () => { + //GIVEN + + //WHEN + const results = (await LeaderboardsDal.get( + "time", + "60", + "english", + 1, + 2, + false, + [] + )) as LeaderboardsDal.DBLeaderboardEntry[]; + //THEN + expect(results).toEqual([]); + }); + }); + describe("getCount / getRank", () => { + it("should get count", async () => { + //GIVEN + await createUser(lbBests(undefined, pb(105))); + await createUser(lbBests(undefined, pb(100))); + const me = await createUser(lbBests(undefined, pb(95))); + await createUser(lbBests(undefined, pb(90))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN / THEN + + expect(await LeaderboardsDal.getCount("time", "60", "english")) // + .toEqual(4); + expect(await LeaderboardsDal.getRank("time", "60", "english", me.uid)) // + .toEqual( + expect.objectContaining({ + wpm: 95, + rank: 3, + name: me.name, + uid: me.uid, + }) + ); + }); + it("should get for friends only", async () => { + //GIVEN + const friendOne = await createUser(lbBests(undefined, pb(105))); + await createUser(lbBests(undefined, pb(100))); + await createUser(lbBests(undefined, pb(95))); + const friendTwo = await createUser(lbBests(undefined, pb(90))); + const me = await createUser(lbBests(undefined, pb(99))); + + console.log("me", me.uid); + + await LeaderboardsDal.update("time", "60", "english"); + + const friends = [friendOne.uid, friendTwo.uid, me.uid]; + + //WHEN / THEN + + expect(await LeaderboardsDal.getCount("time", "60", "english", friends)) // + .toEqual(3); + expect( + await LeaderboardsDal.getRank("time", "60", "english", me.uid, friends) + ) // + .toEqual( + expect.objectContaining({ + wpm: 99, + rank: 3, + friendsRank: 2, + name: me.name, + uid: me.uid, + }) + ); }); }); }); function expectedLbEntry( time: string, - { rank, user, badgeId, isPremium }: ExpectedLbEntry + { rank, user, badgeId, isPremium, friendsRank }: ExpectedLbEntry ) { // @ts-expect-error const lbBest: PersonalBest = @@ -299,6 +453,7 @@ function expectedLbEntry( discordAvatar: user.discordAvatar, badgeId, isPremium, + friendsRank, }; } @@ -351,4 +506,5 @@ type ExpectedLbEntry = { user: UserDal.DBUser; badgeId?: number; isPremium?: boolean; + friendsRank?: number; }; diff --git a/backend/__tests__/__integration__/dal/preset.spec.ts b/backend/__tests__/__integration__/dal/preset.spec.ts index 254f28851f6f..6c3e8fefb960 100644 --- a/backend/__tests__/__integration__/dal/preset.spec.ts +++ b/backend/__tests__/__integration__/dal/preset.spec.ts @@ -375,7 +375,7 @@ describe("PresetDal", () => { ).presetId; //WHEN - PresetDal.removePreset(uid, first); + await PresetDal.removePreset(uid, first); //THEN const read = await PresetDal.getPresets(uid); diff --git a/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts b/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts index 7f5a7efdbe87..6dbb8af55c8d 100644 --- a/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts +++ b/backend/__tests__/__integration__/utils/daily-leaderboards.spec.ts @@ -120,10 +120,14 @@ describe("Daily Leaderboards", () => { true ); //THEN - expect(results).toEqual([ - { rank: 1, ...bestResult }, - { rank: 2, ...user2 }, - ]); + expect(results).toEqual({ + count: 2, + minWpm: 20, + entries: [ + { rank: 1, ...bestResult }, + { rank: 2, ...user2 }, + ], + }); }); it("limits max amount of results", async () => { @@ -136,7 +140,9 @@ describe("Daily Leaderboards", () => { .fill(0) .map(() => givenResult({ wpm: 20 + Math.random() * 100 })) ); - expect(await lb.getCount()).toEqual(maxResults); + expect( + await lb.getResults(0, 5, dailyLeaderboardsConfig, true) + ).toEqual(expect.objectContaining({ count: maxResults })); expect(await lb.getRank(bob.uid, dailyLeaderboardsConfig)).toEqual({ rank: maxResults, ...bob, @@ -147,7 +153,9 @@ describe("Daily Leaderboards", () => { //THEN //max count is still the same, but bob is no longer on the leaderboard - expect(await lb.getCount()).toEqual(maxResults); + expect( + await lb.getResults(0, 5, dailyLeaderboardsConfig, true) + ).toEqual(expect.objectContaining({ count: maxResults })); expect(await lb.getRank(bob.uid, dailyLeaderboardsConfig)).toBeNull(); }); }); @@ -166,11 +174,15 @@ describe("Daily Leaderboards", () => { true ); //THEN - expect(results).toEqual([ - { rank: 1, ...user2 }, - { rank: 2, ...user1 }, - { rank: 3, ...user3 }, - ]); + expect(results).toEqual({ + count: 3, + minWpm: 40, + entries: [ + { rank: 1, ...user2 }, + { rank: 2, ...user1 }, + { rank: 3, ...user3 }, + ], + }); }); it("gets result for page", async () => { //GIVEN @@ -188,10 +200,14 @@ describe("Daily Leaderboards", () => { true ); //THEN - expect(results).toEqual([ - { rank: 3, ...user4 }, - { rank: 4, ...user3 }, - ]); + expect(results).toEqual({ + count: 5, + minWpm: 20, + entries: [ + { rank: 3, ...user4 }, + { rank: 4, ...user3 }, + ], + }); }); it("gets result without premium", async () => { @@ -208,57 +224,140 @@ describe("Daily Leaderboards", () => { false ); //THEN - expect(results).toEqual([ - { rank: 1, ...user2, isPremium: undefined }, - { rank: 2, ...user1, isPremium: undefined }, - { rank: 3, ...user3, isPremium: undefined }, - ]); + expect(results).toEqual({ + count: 3, + minWpm: 40, + entries: [ + { rank: 1, ...user2, isPremium: undefined }, + { rank: 2, ...user1, isPremium: undefined }, + { rank: 3, ...user3, isPremium: undefined }, + ], + }); }); - }); - describe("minWPm", () => { - it("gets min wpm", async () => { + it("should get for friends only", async () => { //GIVEN - await givenResult({ wpm: 50 }); - await givenResult({ wpm: 60 }); + const _user1 = await givenResult({ wpm: 90 }); + const user2 = await givenResult({ wpm: 80 }); + const _user3 = await givenResult({ wpm: 70 }); + const user4 = await givenResult({ wpm: 60 }); + const _user5 = await givenResult({ wpm: 50 }); //WHEN - const minWpm = await lb.getMinWpm(dailyLeaderboardsConfig); + const results = await lb.getResults( + 0, + 5, + dailyLeaderboardsConfig, + true, + [user2.uid, user4.uid, new ObjectId().toHexString()] + ); //THEN - expect(minWpm).toEqual(50); + expect(results).toEqual({ + count: 2, + minWpm: 60, + entries: [ + { rank: 2, friendsRank: 1, ...user2 }, + { rank: 4, friendsRank: 2, ...user4 }, + ], + }); }); - }); - describe("getRank", () => { - it("gets rank", async () => { + it("should get for friends only with page", async () => { //GIVEN - const user1 = await givenResult({ wpm: 50 }); - const _user2 = await givenResult({ wpm: 60 }); + const user1 = await givenResult({ wpm: 105 }); + const user2 = await givenResult({ wpm: 100 }); + const _user3 = await givenResult({ wpm: 95 }); + const user4 = await givenResult({ wpm: 90 }); + const _user5 = await givenResult({ wpm: 70 }); //WHEN - const rank = await lb.getRank(user1.uid, dailyLeaderboardsConfig); + + const results = await lb.getResults( + 1, + 2, + dailyLeaderboardsConfig, + true, + [user1.uid, user2.uid, user4.uid, new ObjectId().toHexString()] + ); + //THEN - expect(rank).toEqual({ rank: 2, ...user1 }); + expect(results).toEqual({ + count: 3, + minWpm: 90, + entries: [{ rank: 4, friendsRank: 3, ...user4 }], + }); }); - }); - describe("getCount", () => { - it("gets count", async () => { + it("should return empty list if no friends", async () => { //GIVEN - await givenResult({ wpm: 50 }); - await givenResult({ wpm: 60 }); //WHEN - const count = await lb.getCount(); + const results = await lb.getResults( + 0, + 5, + dailyLeaderboardsConfig, + true, + [] + ); //THEN - expect(count).toEqual(2); + expect(results).toEqual({ + count: 0, + minWpm: 0, + entries: [], + }); + }); + }); + + describe("getRank", () => { + it("gets rank", async () => { + //GIVEN + const user1 = await givenResult({ wpm: 50 }); + const user2 = await givenResult({ wpm: 60 }); + + //WHEN / THEN + expect(await lb.getRank(user1.uid, dailyLeaderboardsConfig)).toEqual({ + rank: 2, + ...user1, + }); + expect(await lb.getRank(user2.uid, dailyLeaderboardsConfig)).toEqual({ + rank: 1, + ...user2, + }); + }); + + it("should return null for unknown user", async () => { + expect(await lb.getRank("decoy", dailyLeaderboardsConfig)).toBeNull(); + expect( + await lb.getRank("decoy", dailyLeaderboardsConfig, [ + "unknown", + "unknown2", + ]) + ).toBeNull(); + }); + + it("gets rank for friends", async () => { + //GIVEN + const user1 = await givenResult({ wpm: 50 }); + const user2 = await givenResult({ wpm: 60 }); + const _user3 = await givenResult({ wpm: 70 }); + const friends = [user1.uid, user2.uid, "decoy"]; + + //WHEN / THEN + expect( + await lb.getRank(user2.uid, dailyLeaderboardsConfig, friends) + ).toEqual({ rank: 2, friendsRank: 1, ...user2 }); + + expect( + await lb.getRank(user1.uid, dailyLeaderboardsConfig, friends) + ).toEqual({ rank: 3, friendsRank: 2, ...user1 }); }); }); it("purgeUserFromDailyLeaderboards", async () => { //GIVEN const cheater = await givenResult({ wpm: 50 }); - const validUser = await givenResult(); + const user1 = await givenResult({ wpm: 60 }); + const user2 = await givenResult({ wpm: 40 }); //WHEN await DailyLeaderboards.purgeUserFromDailyLeaderboards( @@ -268,7 +367,14 @@ describe("Daily Leaderboards", () => { //THEN expect(await lb.getRank(cheater.uid, dailyLeaderboardsConfig)).toBeNull(); expect(await lb.getResults(0, 50, dailyLeaderboardsConfig, true)).toEqual( - [{ rank: 1, ...validUser }] + { + count: 2, + minWpm: 40, + entries: [ + { rank: 1, ...user1 }, + { rank: 2, ...user2 }, + ], + } ); }); diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts new file mode 100644 index 000000000000..b3590fa4a629 --- /dev/null +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -0,0 +1,459 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import request, { Test as SuperTest } from "supertest"; +import app from "../../../src/app"; +import { mockBearerAuthentication } from "../../__testData__/auth"; +import * as Configuration from "../../../src/init/configuration"; +import { ObjectId } from "mongodb"; +import _ from "lodash"; +import * as FriendsDal from "../../../src/dal/friends"; +import * as UserDal from "../../../src/dal/user"; + +const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); +const uid = new ObjectId().toHexString(); +const mockAuth = mockBearerAuthentication(uid); + +describe("FriendsController", () => { + beforeEach(async () => { + await enableFriendsEndpoints(true); + vi.useFakeTimers(); + vi.setSystemTime(1000); + mockAuth.beforeEach(); + }); + + describe("get friend requests", () => { + const getFriendsMock = vi.spyOn(FriendsDal, "getRequests"); + + beforeEach(() => { + getFriendsMock.mockClear(); + }); + + it("should get for the current user", async () => { + //GIVEN + const friend: FriendsDal.DBFriendRequest = { + _id: new ObjectId(), + addedAt: 42, + initiatorUid: new ObjectId().toHexString(), + initiatorName: "Bob", + friendUid: new ObjectId().toHexString(), + friendName: "Kevin", + status: "pending", + key: "key", + }; + + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/friends/requests") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual([{ ...friend, _id: friend._id.toHexString() }]); + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + }); + }); + + it("should filter by status", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + status: ["accepted"], + }); + }); + + it("should filter by multiple status", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ status: ["accepted", "blocked"] }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + status: ["accepted", "blocked"], + }); + }); + + it("should filter by type incoming", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ type: "incoming" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + friendUid: uid, + }); + }); + + it("should filter by type outgoing", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ type: "outgoing" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + }); + }); + + it("should filter by multiple types", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ type: ["incoming", "outgoing"] }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + }); + }); + + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.get("/friends/requests").set("Authorization", `Bearer ${uid}`) + ); + }); + it("should fail without authentication", async () => { + await mockApp.get("/friends/requests").expect(401); + }); + it("should fail for unknown query parameter", async () => { + const { body } = await mockApp + .get("/friends/requests") + .query({ extra: "yes" }) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + expect(body).toStrictEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + + describe("create friend request", () => { + const getUserByNameMock = vi.spyOn(UserDal, "getUserByName"); + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const createUserMock = vi.spyOn(FriendsDal, "create"); + + beforeEach(() => { + [getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) => + it.mockClear() + ); + }); + + it("should create", async () => { + //GIVEN + const me = { uid, name: "Bob" }; + const myFriend = { uid: new ObjectId().toHexString(), name: "Kevin" }; + getUserByNameMock.mockResolvedValue(myFriend as any); + getPartialUserMock.mockResolvedValue(me as any); + + const result: FriendsDal.DBFriendRequest = { + _id: new ObjectId(), + addedAt: 42, + initiatorUid: me.uid, + initiatorName: me.name, + friendUid: myFriend.uid, + friendName: myFriend.name, + key: "test", + status: "pending", + }; + createUserMock.mockResolvedValue(result); + + //WHEN + const { body } = await mockApp + .post("/friends/requests") + .send({ friendName: "Kevin" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual({ + _id: result._id.toHexString(), + addedAt: 42, + initiatorUid: me.uid, + initiatorName: me.name, + friendUid: myFriend.uid, + friendName: myFriend.name, + status: "pending", + }); + + expect(getUserByNameMock).toHaveBeenCalledWith("Kevin", "create friend"); + expect(getPartialUserMock).toHaveBeenCalledWith(uid, "create friend", [ + "uid", + "name", + ]); + expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100); + }); + + it("should fail if user and friend are the same", async () => { + //GIVEN + const me = { uid, name: "Bob" }; + + getUserByNameMock.mockResolvedValue(me as any); + getPartialUserMock.mockResolvedValue(me as any); + + //WHEN + const { body } = await mockApp + .post("/friends/requests") + .send({ friendName: "Bob" }) + .set("Authorization", `Bearer ${uid}`) + .expect(400); + + //THEN + expect(body.message).toEqual("You cannot be your own friend, sorry."); + }); + + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/friends/requests") + .send({}) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [`"friendName" Required`], + }); + }); + it("should fail with extra properties", async () => { + //WHEN + const { body } = await mockApp + .post("/friends/requests") + .send({ friendName: "1", extra: "value" }) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp + .post("/friends/requests") + .send({ friendName: "1" }) + .set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.post("/friends/requests").expect(401); + }); + }); + + describe("delete friend request", () => { + const deleteByIdMock = vi.spyOn(FriendsDal, "deleteById"); + + beforeEach(() => { + deleteByIdMock.mockClear().mockResolvedValue(); + }); + + it("should delete by id", async () => { + //WHEN + await mockApp + .delete("/friends/requests/1") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(deleteByIdMock).toHaveBeenCalledWith(uid, "1"); + }); + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp + .delete("/friends/requests/1") + .set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.delete("/friends/requests/1").expect(401); + }); + }); + + describe("update friend request", () => { + const updateStatusMock = vi.spyOn(FriendsDal, "updateStatus"); + + beforeEach(() => { + updateStatusMock.mockClear().mockResolvedValue(); + }); + + it("should accept", async () => { + //WHEN + await mockApp + .patch("/friends/requests/1") + .send({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "accepted"); + }); + it("should block", async () => { + //WHEN + await mockApp + .patch("/friends/requests/1") + .send({ status: "blocked" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "blocked"); + }); + + it("should fail for invalid status", async () => { + const { body } = await mockApp + .patch("/friends/requests/1") + .send({ status: "invalid" }) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"status" Invalid enum value. Expected 'accepted' | 'blocked', received 'invalid'`, + ], + }); + }); + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp + .patch("/friends/requests/1") + .send({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp + .patch("/friends/requests/1") + .send({ status: "accepted" }) + .expect(401); + }); + }); + describe("get friends", () => { + const getFriendsMock = vi.spyOn(FriendsDal, "getFriends"); + + beforeEach(() => { + getFriendsMock.mockClear(); + }); + + it("gets with premium enabled", async () => { + //GIVEN + enablePremiumFeatures(true); + const friend: FriendsDal.DBFriend = { + name: "Bob", + isPremium: true, + } as any; + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/friends") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual([{ name: "Bob", isPremium: true }]); + }); + + it("gets with premium disabled", async () => { + //GIVEN + enablePremiumFeatures(false); + const friend: FriendsDal.DBFriend = { + name: "Bob", + isPremium: true, + } as any; + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/friends") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual([{ name: "Bob" }]); + }); + + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.get("/friends").set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.get("/friends").expect(401); + }); + }); +}); + +async function enableFriendsEndpoints(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + friends: { enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function expectFailForDisabledEndpoint(call: SuperTest): Promise { + await enableFriendsEndpoints(false); + const { body } = await call.expect(503); + expect(body.message).toEqual("Friends are not available at this time."); +} + +async function enablePremiumFeatures(premium: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { premium: { enabled: premium } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index bce16e114ef7..8c1c9c6ec9a6 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -4,6 +4,7 @@ import { ObjectId } from "mongodb"; import request from "supertest"; import app from "../../../src/app"; import * as LeaderboardDal from "../../../src/dal/leaderboards"; +import * as FriendDal from "../../../src/dal/friends"; import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards"; import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; import * as Configuration from "../../../src/init/configuration"; @@ -38,10 +39,13 @@ describe("Loaderboard Controller", () => { describe("get leaderboard", () => { const getLeaderboardMock = vi.spyOn(LeaderboardDal, "get"); const getLeaderboardCountMock = vi.spyOn(LeaderboardDal, "getCount"); + const getFriendsUidsMock = vi.spyOn(FriendDal, "getFriendsUids"); beforeEach(() => { getLeaderboardMock.mockClear(); getLeaderboardCountMock.mockClear(); + getFriendsUidsMock.mockClear(); + getLeaderboardCountMock.mockResolvedValue(42); }); it("should get for english time 60", async () => { @@ -102,14 +106,22 @@ describe("Loaderboard Controller", () => { "english", 0, 50, - false + false, + undefined + ); + + expect(getLeaderboardCountMock).toHaveBeenCalledWith( + "time", + "60", + "english", + undefined ); }); it("should get for english time 60 with page", async () => { //GIVEN getLeaderboardMock.mockResolvedValue([]); - getLeaderboardCountMock.mockResolvedValue(0); + getLeaderboardCountMock.mockResolvedValue(42); const page = 0; const pageSize = 25; @@ -130,7 +142,7 @@ describe("Loaderboard Controller", () => { expect(body).toEqual({ message: "Leaderboard retrieved", data: { - count: 0, + count: 42, pageSize: 25, entries: [], }, @@ -142,7 +154,48 @@ describe("Loaderboard Controller", () => { "english", page, pageSize, - false + false, + undefined + ); + }); + + it("should get for friendsOnly", async () => { + //GIVEN + await enableFriendsFeature(true); + getLeaderboardMock.mockResolvedValue([]); + getFriendsUidsMock.mockResolvedValue(["uidOne", "uidTwo"]); + getLeaderboardCountMock.mockResolvedValue(2); + + //WHEN + + const { body } = await mockApp + .get("/leaderboards") + .set("Authorization", `Bearer ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .expect(200); + + //THEN + expect(body.data.count).toEqual(2); + + expect(getLeaderboardMock).toHaveBeenCalledWith( + "time", + "60", + "english", + 0, + 50, + false, + ["uidOne", "uidTwo"] + ); + expect(getLeaderboardCountMock).toHaveBeenCalledWith( + "time", + "60", + "english", + ["uidOne", "uidTwo"] ); }); @@ -242,9 +295,11 @@ describe("Loaderboard Controller", () => { describe("get rank", () => { const getLeaderboardRankMock = vi.spyOn(LeaderboardDal, "getRank"); + const getFriendsUidsMock = vi.spyOn(FriendDal, "getFriendsUids"); afterEach(() => { getLeaderboardRankMock.mockClear(); + getFriendsUidsMock.mockClear(); }); it("fails withouth authentication", async () => { @@ -256,10 +311,9 @@ describe("Loaderboard Controller", () => { it("should get for english time 60", async () => { //GIVEN - const entryId = new ObjectId(); const resultEntry = { - _id: entryId.toHexString(), + _id: entryId, wpm: 10, acc: 80, timestamp: 1200, @@ -271,7 +325,6 @@ describe("Loaderboard Controller", () => { getLeaderboardRankMock.mockResolvedValue(resultEntry); //WHEN - const { body } = await mockApp .get("/leaderboards/rank") .query({ language: "english", mode: "time", mode2: "60" }) @@ -281,16 +334,48 @@ describe("Loaderboard Controller", () => { //THEN expect(body).toEqual({ message: "Rank retrieved", - data: resultEntry, + data: { ...resultEntry, _id: undefined }, }); expect(getLeaderboardRankMock).toHaveBeenCalledWith( "time", "60", "english", - uid + uid, + undefined + ); + }); + + it("should get for english time 60 friends only", async () => { + //GIVEN + await enableFriendsFeature(true); + const friends = ["friendOne", "friendTwo"]; + getFriendsUidsMock.mockResolvedValue(friends); + getLeaderboardRankMock.mockResolvedValue({} as any); + + //WHEN + await mockApp + .get("/leaderboards/rank") + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getLeaderboardRankMock).toHaveBeenCalledWith( + "time", + "60", + "english", + uid, + friends ); + expect(getFriendsUidsMock).toHaveBeenCalledWith(uid); }); + it("should get with ape key", async () => { await acceptApeKeys(true); const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); @@ -401,18 +486,24 @@ describe("Loaderboard Controller", () => { DailyLeaderboards, "getDailyLeaderboard" ); + const getFriendsUidsMock = vi.spyOn(FriendDal, "getFriendsUids"); + + const getResultMock = vi.fn(); beforeEach(async () => { - getDailyLeaderboardMock.mockClear(); + [getDailyLeaderboardMock, getFriendsUidsMock, getResultMock].forEach( + (it) => it.mockClear() + ); + vi.useFakeTimers(); vi.setSystemTime(1722606812000); await dailyLeaderboardEnabled(true); getDailyLeaderboardMock.mockReturnValue({ - getResults: () => Promise.resolve([]), - getCount: () => Promise.resolve(0), - getMinWpm: () => Promise.resolve(0), + getResults: getResultMock, } as any); + + getResultMock.mockResolvedValue(null); }); afterEach(() => { @@ -452,20 +543,11 @@ describe("Loaderboard Controller", () => { ], }; - const getResultMock = vi.fn(); - getResultMock.mockResolvedValue(resultData); - - const getCountMock = vi.fn(); - getCountMock.mockResolvedValue(2); - - const getMinWpmMock = vi.fn(); - getMinWpmMock.mockResolvedValue(10); - - getDailyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - getMinWpm: getMinWpmMock, - } as any); + getResultMock.mockResolvedValue({ + count: 2, + minWpm: 10, + entries: resultData, + }); //WHEN const { body } = await mockApp @@ -492,7 +574,13 @@ describe("Loaderboard Controller", () => { -1 ); - expect(getResultMock).toHaveBeenCalledWith(0, 50, lbConf, premiumEnabled); + expect(getResultMock).toHaveBeenCalledWith( + 0, + 50, + lbConf, + premiumEnabled, + undefined + ); }); it("should get for english time 60 for yesterday", async () => { @@ -536,20 +624,7 @@ describe("Loaderboard Controller", () => { const page = 2; const pageSize = 25; - const getResultMock = vi.fn(); - getResultMock.mockResolvedValue([]); - - const getCountMock = vi.fn(); - getCountMock.mockResolvedValue(0); - - const getMinWpmMock = vi.fn(); - getMinWpmMock.mockResolvedValue(0); - - getDailyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - getMinWpm: getMinWpmMock, - } as any); + getResultMock.mockResolvedValue({ entries: [] }); //WHEN const { body } = await mockApp @@ -586,7 +661,8 @@ describe("Loaderboard Controller", () => { page, pageSize, lbConf, - premiumEnabled + premiumEnabled, + undefined ); }); @@ -626,6 +702,48 @@ describe("Loaderboard Controller", () => { } }); + it("should get for friends", async () => { + //GIVEN + const lbConf = (await configuration).dailyLeaderboards; + const premiumEnabled = (await configuration).users.premium.enabled; + await enableFriendsFeature(true); + const friends = [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]; + getFriendsUidsMock.mockResolvedValue(friends); + + //WHEN + await mockApp + .get("/leaderboards/daily") + .set("Authorization", `Bearer ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .expect(200); + + //THEN + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getResultMock).toHaveBeenCalledWith( + 0, + 50, + lbConf, + premiumEnabled, + friends + ); + }); + it("should get for mode2", async () => { for (const mode2 of allModes) { const response = await mockApp @@ -708,15 +826,21 @@ describe("Loaderboard Controller", () => { "getDailyLeaderboard" ); + const getRankMock = vi.fn(); + const getFriendsUidsMock = vi.spyOn(FriendDal, "getFriendsUids"); + beforeEach(async () => { getDailyLeaderboardMock.mockClear(); - vi.useFakeTimers(); - vi.setSystemTime(1722606812000); - await dailyLeaderboardEnabled(true); + getRankMock.mockClear(); + getFriendsUidsMock.mockClear(); getDailyLeaderboardMock.mockReturnValue({ - getRank: () => Promise.resolve({} as any), + getRank: getRankMock, } as any); + + vi.useFakeTimers(); + vi.setSystemTime(1722606812000); + await dailyLeaderboardEnabled(true); }); afterEach(() => { @@ -730,6 +854,7 @@ describe("Loaderboard Controller", () => { .query({ language: "english", mode: "time", mode2: "60" }) .expect(401); }); + it("should get for english time 60", async () => { //GIVEN const lbConf = (await configuration).dailyLeaderboards; @@ -749,11 +874,7 @@ describe("Loaderboard Controller", () => { }, }; - const getRankMock = vi.fn(); getRankMock.mockResolvedValue(rankData); - getDailyLeaderboardMock.mockReturnValue({ - getRank: getRankMock, - } as any); //WHEN const { body } = await mockApp @@ -776,8 +897,43 @@ describe("Loaderboard Controller", () => { -1 ); - expect(getRankMock).toHaveBeenCalledWith(uid, lbConf); + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, undefined); + }); + + it("should get for english time 60 friends only", async () => { + //GIVEN + await enableFriendsFeature(true); + const lbConf = (await configuration).dailyLeaderboards; + getRankMock.mockResolvedValue({}); + const friends = ["friendOne", "friendTwo"]; + getFriendsUidsMock.mockResolvedValue(friends); + + //WHEN + await mockApp + .get("/leaderboards/daily/rank") + .set("Authorization", `Bearer ${uid}`) + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .expect(200); + + //THEN + + expect(getDailyLeaderboardMock).toHaveBeenCalledWith( + "english", + "time", + "60", + lbConf, + -1 + ); + + expect(getRankMock).toHaveBeenCalledWith(uid, lbConf, friends); + expect(getFriendsUidsMock).toHaveBeenCalledWith(uid); }); + it("fails if daily leaderboards are disabled", async () => { await dailyLeaderboardEnabled(false); @@ -790,6 +946,7 @@ describe("Loaderboard Controller", () => { "Daily leaderboards are not available at this time." ); }); + it("should get for mode", async () => { for (const mode of ["time", "words", "quote", "zen", "custom"]) { const response = await mockApp @@ -799,6 +956,7 @@ describe("Loaderboard Controller", () => { expect(response.status, "for mode " + mode).toEqual(200); } }); + it("should get for mode2", async () => { for (const mode2 of allModes) { const response = await mockApp @@ -884,17 +1042,25 @@ describe("Loaderboard Controller", () => { describe("get xp weekly leaderboard", () => { const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + const getResultMock = vi.fn(); + const getCountMock = vi.fn(); beforeEach(async () => { getXpWeeklyLeaderboardMock.mockClear(); + getResultMock.mockClear(); + getCountMock.mockClear(); + vi.useFakeTimers(); vi.setSystemTime(1722606812000); await weeklyLeaderboardEnabled(true); getXpWeeklyLeaderboardMock.mockReturnValue({ - getResults: () => Promise.resolve([]), - getCount: () => Promise.resolve(0), + getResults: getResultMock, + getCount: getCountMock, } as any); + + getResultMock.mockResolvedValue([]); + getCountMock.mockResolvedValue(0); }); afterEach(() => { @@ -928,17 +1094,9 @@ describe("Loaderboard Controller", () => { }, ]; - const getResultMock = vi.fn(); getResultMock.mockResolvedValue(resultData); - - const getCountMock = vi.fn(); getCountMock.mockResolvedValue(2); - getXpWeeklyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - } as any); - //WHEN const { body } = await mockApp .get("/leaderboards/xp/weekly") @@ -994,17 +1152,9 @@ describe("Loaderboard Controller", () => { const page = 2; const pageSize = 25; - const getResultMock = vi.fn(); getResultMock.mockResolvedValue([]); - - const getCountMock = vi.fn(); getCountMock.mockResolvedValue(0); - getXpWeeklyLeaderboardMock.mockReturnValue({ - getResults: getResultMock, - getCount: getCountMock, - } as any); - //WHEN const { body } = await mockApp .get("/leaderboards/xp/weekly") @@ -1078,12 +1228,19 @@ describe("Loaderboard Controller", () => { describe("get xp weekly leaderboard rank", () => { const getXpWeeklyLeaderboardMock = vi.spyOn(WeeklyXpLeaderboard, "get"); + const getRankMock = vi.fn(); beforeEach(async () => { getXpWeeklyLeaderboardMock.mockClear(); + getRankMock.mockClear(); + await weeklyLeaderboardEnabled(true); vi.useFakeTimers(); vi.setSystemTime(1722606812000); + + getXpWeeklyLeaderboardMock.mockReturnValue({ + getRank: getRankMock, + } as any); }); it("fails withouth authentication", async () => { @@ -1104,11 +1261,8 @@ describe("Loaderboard Controller", () => { discordAvatar: "discordAvatar", lastActivityTimestamp: 1000, }; - const getRankMock = vi.fn(); + getRankMock.mockResolvedValue(resultData); - getXpWeeklyLeaderboardMock.mockReturnValue({ - getRank: getRankMock, - } as any); //WHEN const { body } = await mockApp @@ -1141,11 +1295,7 @@ describe("Loaderboard Controller", () => { discordAvatar: "discordAvatar", lastActivityTimestamp: 1000, }; - const getRankMock = vi.fn(); getRankMock.mockResolvedValue(resultData); - getXpWeeklyLeaderboardMock.mockReturnValue({ - getRank: getRankMock, - } as any); //WHEN const { body } = await mockApp @@ -1253,3 +1403,13 @@ async function weeklyLeaderboardEnabled(enabled: boolean): Promise { mockConfig ); } + +async function enableFriendsFeature(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + friends: { enabled: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index cf5e62007222..031bfbdbdba7 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -40,6 +40,7 @@ import { MonkeyMail, UserStreak } from "@monkeytype/schemas/users"; import MonkeyError, { isFirebaseError } from "../../../src/utils/error"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; +import * as FriendsDal from "../../../src/dal/friends"; import { pb } from "../../__testData__/users"; const mockApp = request(app); @@ -634,6 +635,7 @@ describe("user controller test", () => { "purgeUserFromXpLeaderboards" ); const blocklistAddMock = vi.spyOn(BlocklistDal, "add"); + const friendsDeletebyUidMock = vi.spyOn(FriendsDal, "deleteByUid"); const logsDeleteUserMock = vi.spyOn(LogDal, "deleteUserLogs"); beforeEach(() => { @@ -647,6 +649,7 @@ describe("user controller test", () => { deleteConfigMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, + friendsDeletebyUidMock, logsDeleteUserMock, ].forEach((it) => it.mockResolvedValue(undefined)); @@ -665,6 +668,7 @@ describe("user controller test", () => { deleteAllPresetsMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, + friendsDeletebyUidMock, logsDeleteUserMock, ].forEach((it) => it.mockClear()); }); @@ -695,6 +699,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -730,6 +735,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -760,6 +766,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -789,6 +796,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).not.toHaveBeenCalledWith(uid); expect(deleteConfigMock).not.toHaveBeenCalledWith(uid); expect(deleteAllResultMock).not.toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).not.toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).not.toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -829,6 +837,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -869,6 +878,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -989,6 +999,7 @@ describe("user controller test", () => { const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const updateNameMock = vi.spyOn(UserDal, "updateName"); + const friendsUpdateNameMock = vi.spyOn(FriendsDal, "updateName"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { @@ -996,6 +1007,7 @@ describe("user controller test", () => { blocklistContainsMock, getPartialUserMock, updateNameMock, + friendsUpdateNameMock, addImportantLogMock, ].forEach((it) => { it.mockClear().mockResolvedValue(null as never); @@ -1027,6 +1039,7 @@ describe("user controller test", () => { "changed name from Bob to newName", uid ); + expect(friendsUpdateNameMock).toHaveBeenCalledWith(uid, "newName"); }); it("should fail if username is blocked", async () => { @@ -1043,6 +1056,7 @@ describe("user controller test", () => { //THEN expect(body.message).toEqual("Username blocked"); expect(updateNameMock).not.toHaveBeenCalled(); + expect(friendsUpdateNameMock).not.toHaveBeenCalled(); }); it("should fail for banned users", async () => { diff --git a/backend/redis-scripts/get-rank.lua b/backend/redis-scripts/get-rank.lua new file mode 100644 index 000000000000..fe15f6694879 --- /dev/null +++ b/backend/redis-scripts/get-rank.lua @@ -0,0 +1,51 @@ +-- Helper to split CSV string into a list +local function split_csv(csv) + local result = {} + for user_id in string.gmatch(csv, '([^,]+)') do + table.insert(result, user_id) + end + return result +end + +local redis_call = redis.call +local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] + +local user_id = ARGV[1] +local include_scores = ARGV[2] +local user_ids_csv = ARGV[3] + +local rank = nil +local friendsRank = nil +local result = {} +local score = '' + + +-- filtered leaderboard +if user_ids_csv ~= "" then + + local filtered_user_ids = split_csv(user_ids_csv) + local scored_users = {} + for _, user_id in ipairs(filtered_user_ids) do + local score = redis_call('ZSCORE', leaderboard_scores_key, user_id) + if score then + local number_score = tonumber(score) + table.insert(scored_users, {user_id = user_id, score = number_score}) + end + end + table.sort(scored_users, function(a, b) return a.score > b.score end) + + for i = 1, #scored_users do + if scored_users[i].user_id == user_id then + friendsRank = i - 1 + end + end + +end + +rank = redis_call('ZREVRANK', leaderboard_scores_key, user_id) +if (include_scores == "true") then + score = redis_call('ZSCORE', leaderboard_scores_key, user_id) +end +result = redis_call('HGET', leaderboard_results_key, user_id) + +return {rank, score, result, friendsRank} \ No newline at end of file diff --git a/backend/redis-scripts/get-results.lua b/backend/redis-scripts/get-results.lua index 3897517c3002..31049f7050e6 100644 --- a/backend/redis-scripts/get-results.lua +++ b/backend/redis-scripts/get-results.lua @@ -1,24 +1,84 @@ +-- Helper to split CSV string into a list +local function split_csv(csv) + local result = {} + for user_id in string.gmatch(csv, '([^,]+)') do + table.insert(result, user_id) + end + return result +end + local redis_call = redis.call local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] local min_rank = tonumber(ARGV[1]) local max_rank = tonumber(ARGV[2]) local include_scores = ARGV[3] +local user_ids_csv = ARGV[4] local results = {} local scores = {} -local scores_in_range = redis_call('ZRANGE', leaderboard_scores_key, min_rank, max_rank, 'REV') +local ranks = {} +local count = nil +local min_score = {user_id = nil, score = nil} + -for _, user_id in ipairs(scores_in_range) do - local result_data = redis_call('HGET', leaderboard_results_key, user_id) +-- filtered leaderboard +if user_ids_csv ~= "" then - if (include_scores == "true") then - scores[#scores + 1] = redis_call('ZSCORE', leaderboard_scores_key, user_id) + local filtered_user_ids = split_csv(user_ids_csv) + local scored_users = {} + for _, user_id in ipairs(filtered_user_ids) do + local score = redis_call('ZSCORE', leaderboard_scores_key, user_id) + if score then + local number_score = tonumber(score) + table.insert(scored_users, {user_id = user_id, score = number_score}) + end end + table.sort(scored_users, function(a, b) return a.score > b.score end) + + + if #scored_users > 0 then + min_score = {scored_users[#scored_users].user_id, scored_users[#scored_users].score} + end + count = #scored_users + + for i = min_rank + 1, math.min(max_rank + 1, #scored_users) do + local entry = scored_users[i] + local user_id = entry.user_id + local score = entry.score + + local result_data = redis_call('HGET', leaderboard_results_key, user_id) + + if result_data ~= nil then + results[#results + 1] = result_data + + local global_rank = redis_call('ZREVRANK', leaderboard_scores_key, user_id) + ranks[#ranks + 1] = global_rank or -1 -- -1 if not found + end + + if include_scores == "true" then + scores[#scores + 1] = score + end + + end + +else +-- global leaderboard + local scores_in_range = redis_call('ZRANGE', leaderboard_scores_key, min_rank, max_rank, 'REV') + min_score = redis_call('ZRANGE', leaderboard_scores_key, 0, 0, 'WITHSCORES') + count = redis_call('ZCARD', leaderboard_scores_key) + + for _, user_id in ipairs(scores_in_range) do + local result_data = redis_call('HGET', leaderboard_results_key, user_id) + + if (include_scores == "true") then + scores[#scores + 1] = redis_call('ZSCORE', leaderboard_scores_key, user_id) + end - if (result_data ~= nil) then - results[#results + 1] = result_data + if (result_data ~= nil) then + results[#results + 1] = result_data + end end end -return {results, scores} \ No newline at end of file +return {results, scores, count, min_score, ranks} \ No newline at end of file diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index d42e06d05b7e..1ae15b4525b7 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -99,6 +99,12 @@ export function getOpenApi(): OpenAPIObject { description: "All-time and daily leaderboards of the fastest typers.", "x-displayName": "Leaderboards", }, + { + name: "friends", + description: "User friend requests and friends list.", + "x-displayName": "Friends", + "x-public": "no", + }, { name: "psas", description: "Public service announcements.", diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts new file mode 100644 index 000000000000..b556c0f982bf --- /dev/null +++ b/backend/src/api/controllers/friends.ts @@ -0,0 +1,104 @@ +import { + CreateFriendRequestRequest, + CreateFriendRequestResponse, + GetFriendRequestsQuery, + GetFriendRequestsResponse, + GetFriendsResponse, + IdPathParams, + UpdateFriendRequestsRequest, +} from "@monkeytype/contracts/friends"; +import { MonkeyRequest } from "../types"; +import { MonkeyResponse } from "../../utils/monkey-response"; +import * as FriendsDal from "../../dal/friends"; +import * as UserDal from "../../dal/user"; +import { replaceObjectId, replaceObjectIds } from "../../utils/misc"; +import MonkeyError from "../../utils/error"; +import { omit } from "lodash"; +import { FriendRequest } from "@monkeytype/schemas/friends"; + +export async function getRequests( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { status, type } = req.query; + + const results = await FriendsDal.getRequests({ + initiatorUid: + type === undefined || type.includes("outgoing") ? uid : undefined, + friendUid: + type === undefined || type?.includes("incoming") ? uid : undefined, + status: status, + }); + + return new MonkeyResponse( + "Friend requests retrieved", + replaceObjectIds(results) + ); +} + +export async function createRequest( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { friendName } = req.body; + const { maxFriendsPerUser } = req.ctx.configuration.friends; + + const friend = await UserDal.getUserByName(friendName, "create friend"); + + if (uid === friend.uid) { + throw new MonkeyError(400, "You cannot be your own friend, sorry."); + } + + const initiator = await UserDal.getPartialUser(uid, "create friend", [ + "uid", + "name", + ]); + + const result: FriendRequest = omit( + replaceObjectId( + await FriendsDal.create(initiator, friend, maxFriendsPerUser) + ), + "key" + ); + + return new MonkeyResponse("Friend created", result); +} + +export async function deleteRequest( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { id } = req.params; + + await FriendsDal.deleteById(uid, id); + + return new MonkeyResponse("Friend deleted", null); +} + +export async function updateRequest( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { id } = req.params; + const { status } = req.body; + + await FriendsDal.updateStatus(uid, id, status); + + return new MonkeyResponse("Friend updated", null); +} + +export async function getFriends( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const premiumEnabled = req.ctx.configuration.users.premium.enabled; + const data = await FriendsDal.getFriends(uid); + + if (!premiumEnabled) { + for (const friend of data) { + delete friend.isPremium; + } + } + + return new MonkeyResponse("Friends retrieved", data); +} diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 689a2341e6c4..395e6379fabe 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; +import * as FriendsDAL from "../../dal/friends"; import MonkeyError from "../../utils/error"; import * as DailyLeaderboards from "../../utils/daily-leaderboards"; import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; @@ -30,7 +31,9 @@ import { MonkeyRequest } from "../types"; export async function getLeaderboard( req: MonkeyRequest ): Promise { - const { language, mode, mode2, page, pageSize } = req.query; + const { language, mode, mode2, page, pageSize, friendsOnly } = req.query; + const { uid } = req.ctx.decodedToken; + const friendConfig = req.ctx.configuration.friends; if ( mode !== "time" || @@ -40,13 +43,20 @@ export async function getLeaderboard( throw new MonkeyError(404, "There is no leaderboard for this mode"); } + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + friendConfig + ); + const leaderboard = await LeaderboardsDAL.get( mode, mode2, language, page, pageSize, - req.ctx.configuration.users.premium.enabled + req.ctx.configuration.users.premium.enabled, + friendUids ); if (leaderboard === false) { @@ -56,7 +66,12 @@ export async function getLeaderboard( ); } - const count = await LeaderboardsDAL.getCount(mode, mode2, language); + const count = await LeaderboardsDAL.getCount( + mode, + mode2, + language, + friendUids + ); const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"])); return new MonkeyResponse("Leaderboard retrieved", { @@ -69,10 +84,23 @@ export async function getLeaderboard( export async function getRankFromLeaderboard( req: MonkeyRequest ): Promise { - const { language, mode, mode2 } = req.query; + const { language, mode, mode2, friendsOnly } = req.query; const { uid } = req.ctx.decodedToken; + const friendConfig = req.ctx.configuration.friends; - const data = await LeaderboardsDAL.getRank(mode, mode2, language, uid); + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + friendConfig + ); + + const data = await LeaderboardsDAL.getRank( + mode, + mode2, + language, + uid, + friendUids + ); if (data === false) { throw new MonkeyError( 503, @@ -80,7 +108,7 @@ export async function getRankFromLeaderboard( ); } - return new MonkeyResponse("Rank retrieved", data); + return new MonkeyResponse("Rank retrieved", _.omit(data, "_id")); } function getDailyLeaderboardWithError( @@ -109,7 +137,15 @@ function getDailyLeaderboardWithError( export async function getDailyLeaderboard( req: MonkeyRequest ): Promise { - const { page, pageSize } = req.query; + const { page, pageSize, friendsOnly } = req.query; + const { uid } = req.ctx.decodedToken; + const friendConfig = req.ctx.configuration.friends; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + friendConfig + ); const dailyLeaderboard = getDailyLeaderboardWithError( req.query, @@ -120,19 +156,14 @@ export async function getDailyLeaderboard( page, pageSize, req.ctx.configuration.dailyLeaderboards, - req.ctx.configuration.users.premium.enabled + req.ctx.configuration.users.premium.enabled, + friendUids ); - const minWpm = await dailyLeaderboard.getMinWpm( - req.ctx.configuration.dailyLeaderboards - ); - - const count = await dailyLeaderboard.getCount(); - return new MonkeyResponse("Daily leaderboard retrieved", { - entries: results, - minWpm, - count, + entries: results?.entries ?? [], + count: results?.count ?? 0, + minWpm: results?.minWpm ?? 0, pageSize, }); } @@ -140,7 +171,15 @@ export async function getDailyLeaderboard( export async function getDailyLeaderboardRank( req: MonkeyRequest ): Promise { + const { friendsOnly } = req.query; const { uid } = req.ctx.decodedToken; + const friendConfig = req.ctx.configuration.friends; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + friendConfig + ); const dailyLeaderboard = getDailyLeaderboardWithError( req.query, @@ -149,7 +188,8 @@ export async function getDailyLeaderboardRank( const rank = await dailyLeaderboard.getRank( uid, - req.ctx.configuration.dailyLeaderboards + req.ctx.configuration.dailyLeaderboards, + friendUids ); return new MonkeyResponse("Daily leaderboard rank retrieved", rank); @@ -213,3 +253,17 @@ export async function getWeeklyXpLeaderboardRank( return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry); } + +async function getFriendsUids( + uid: string, + friendsOnly: boolean, + friendsConfig: Configuration["friends"] +): Promise { + if (uid !== "" && friendsOnly) { + if (!friendsConfig.enabled) { + throw new MonkeyError(503, "This feature is currently unavailable."); + } + return await FriendsDAL.getFriendsUids(uid); + } + return undefined; +} diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 994d7ad72bec..d4ea88c81d6b 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -89,6 +89,7 @@ import { import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { MonkeyRequest } from "../types"; import { tryCatch } from "@monkeytype/util/trycatch"; +import * as FriendsDal from "../../dal/friends"; async function verifyCaptcha(captcha: string): Promise { const { data: verified, error } = await tryCatch(verify(captcha)); @@ -292,6 +293,7 @@ export async function deleteUser(req: MonkeyRequest): Promise { uid, req.ctx.configuration.leaderboards.weeklyXp ), + FriendsDal.deleteByUid(uid), ]); try { @@ -382,6 +384,8 @@ export async function updateName( } await UserDAL.updateName(uid, name, user.name); + + await FriendsDal.updateName(uid, name); void addImportantLog( "user_name_updated", `changed name from ${user.name} to ${name}`, diff --git a/backend/src/api/routes/friends.ts b/backend/src/api/routes/friends.ts new file mode 100644 index 000000000000..a4fb3502feb2 --- /dev/null +++ b/backend/src/api/routes/friends.ts @@ -0,0 +1,24 @@ +import { friendsContract } from "@monkeytype/contracts/friends"; +import { initServer } from "@ts-rest/express"; +import { callController } from "../ts-rest-adapter"; + +import * as FriendsController from "../controllers/friends"; + +const s = initServer(); +export default s.router(friendsContract, { + getRequests: { + handler: async (r) => callController(FriendsController.getRequests)(r), + }, + createRequest: { + handler: async (r) => callController(FriendsController.createRequest)(r), + }, + deleteRequest: { + handler: async (r) => callController(FriendsController.deleteRequest)(r), + }, + updateRequest: { + handler: async (r) => callController(FriendsController.updateRequest)(r), + }, + getFriends: { + handler: async (r) => callController(FriendsController.getFriends)(r), + }, +}); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index d416c9f3b5ea..c0b07a53dc60 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -16,6 +16,7 @@ import configs from "./configs"; import configuration from "./configuration"; import { version } from "../../version"; import leaderboards from "./leaderboards"; +import friends from "./friends"; import addSwaggerMiddlewares from "./swagger"; import { MonkeyResponse } from "../../utils/monkey-response"; import { @@ -61,6 +62,7 @@ const router = s.router(contract, { users, quotes, webhooks, + friends, }); export function addApiRoutes(app: Application): void { diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index d69f74e46698..f372e11afce9 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -103,6 +103,7 @@ export const BASE_CONFIGURATION: Configuration = { xpRewardBrackets: [], }, }, + friends: { enabled: false, maxFriendsPerUser: 100 }, }; type BaseSchema = { @@ -603,5 +604,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { }, }, }, + friends: { + type: "object", + label: "Friends", + fields: { + enabled: { type: "boolean", label: "Enabled" }, + maxFriendsPerUser: { + type: "number", + label: "Max Friends per user", + }, + }, + }, }, }; diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts new file mode 100644 index 000000000000..9b85ad343211 --- /dev/null +++ b/backend/src/dal/friends.ts @@ -0,0 +1,417 @@ +import { Collection, Filter, ObjectId } from "mongodb"; +import * as db from "../init/db"; +import { + Friend, + FriendRequest, + FriendRequestStatus, +} from "@monkeytype/schemas/friends"; +import MonkeyError from "../utils/error"; +import { WithObjectId } from "../utils/misc"; + +export type DBFriendRequest = WithObjectId< + FriendRequest & { + key: string; //sorted uid + } +>; + +export type DBFriend = Friend; + +// Export for use in tests +export const getCollection = (): Collection => + db.collection("friends"); + +export async function getRequests(options: { + initiatorUid?: string; + friendUid?: string; + status?: FriendRequestStatus[]; +}): Promise { + const { initiatorUid, friendUid, status } = options; + + if (initiatorUid === undefined && friendUid === undefined) + throw new Error("no filter provided"); + + let filter: Filter = { $or: [] }; + + if (initiatorUid !== undefined) { + filter.$or?.push({ initiatorUid }); + } + + if (friendUid !== undefined) { + filter.$or?.push({ friendUid }); + } + + if (status !== undefined) { + filter.status = { $in: status }; + } + + return await getCollection().find(filter).toArray(); +} + +export async function create( + initiator: { uid: string; name: string }, + friend: { uid: string; name: string }, + maxFriendsPerUser: number +): Promise { + const count = await getCollection().countDocuments({ + initiatorUid: initiator.uid, + }); + + if (count >= maxFriendsPerUser) { + throw new MonkeyError( + 409, + "Maximum number of friends reached", + "create friend request" + ); + } + try { + const created: DBFriendRequest = { + _id: new ObjectId(), + key: getKey(initiator.uid, friend.uid), + initiatorUid: initiator.uid, + initiatorName: initiator.name, + friendUid: friend.uid, + friendName: friend.name, + addedAt: Date.now(), + status: "pending", + }; + + await getCollection().insertOne(created); + + return created; + } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (e.name === "MongoServerError" && e.code === 11000) { + throw new MonkeyError(409, "Duplicate friend or blocked"); + } + + throw e; + } +} + +/** + *Update the status of a friend by id + * @param friendUid + * @param id + * @param status + * @throws MonkeyError if the friend id is unknown or the friendUid does not match + */ +export async function updateStatus( + friendUid: string, + id: string, + status: FriendRequestStatus +): Promise { + const updateResult = await getCollection().updateOne( + { + _id: new ObjectId(id), + friendUid, + }, + { $set: { status } } + ); + + if (updateResult.matchedCount === 0) { + throw new MonkeyError(404, "Friend not found"); + } +} + +/** + * delete a friend by the id. + * @param uid + * @param id + * @throws MonkeyError if the friend id is unknown or uid does not match + */ +export async function deleteById(uid: string, id: string): Promise { + const deletionResult = await getCollection().deleteOne({ + $and: [ + { + _id: new ObjectId(id), + }, + { + $or: [ + { friendUid: uid }, + { status: { $in: ["accepted", "pending"] }, initiatorUid: uid }, + ], + }, + ], + }); + + if (deletionResult.deletedCount === 0) { + throw new MonkeyError(404, "Cannot be deleted"); + } +} + +/** + * Update all friends for the uid (initiator or friend) with the given name. + * @param uid + * @param newName + */ +export async function updateName(uid: string, newName: string): Promise { + await getCollection().bulkWrite([ + { + updateMany: { + filter: { initiatorUid: uid }, + update: { $set: { initiatorName: newName } }, + }, + }, + { + updateMany: { + filter: { friendUid: uid }, + update: { $set: { friendName: newName } }, + }, + }, + ]); +} + +/** + * Remove all friends containing the uid as initiatorUid or friendUid + * @param uid + */ +export async function deleteByUid(uid: string): Promise { + await getCollection().deleteMany({ + $or: [{ initiatorUid: uid }, { friendUid: uid }], + }); +} + +function getKey(initiatorUid: string, friendUid: string): string { + const ids = [initiatorUid, friendUid]; + ids.sort(); + return ids.join("/"); +} + +export async function getFriends(uid: string): Promise { + return (await getCollection() + .aggregate([ + { + $match: { + //uid is friend or initiator + $and: [ + { + $or: [{ initiatorUid: uid }, { friendUid: uid }], + status: "accepted", + }, + ], + }, + }, + { + $project: { + friendUid: true, + initiatorUid: true, + addedAt: true, + }, + }, + { + $addFields: { + //pick the other user, not uid + uid: { + $cond: { + if: { $eq: ["$friendUid", uid] }, + // oxlint-disable-next-line no-thenable + then: "$initiatorUid", + else: "$friendUid", + }, + }, + }, + }, + // we want to fetch the data for our uid as well, add it to the list of documents + // workaround for missing unionWith + $documents in mongodb 5.0 + { + $group: { + _id: null, + data: { + $push: { + uid: "$uid", + addedAt: "$addedAt", + friendRequestId: "$_id", + }, + }, + }, + }, + { + $project: { + data: { + $concatArrays: ["$data", [{ uid }]], + }, + }, + }, + { + $unwind: "$data", + }, + + /* end of workaround, this is the replacement for >= 5.1 + + { $addFields: { friendRequestId: "$_id" } }, + { $project: { uid: true, addedAt: true, friendRequestId: true } }, + { + $unionWith: { + pipeline: [{ $documents: [{ uid }] }], + }, + }, + */ + + { + $lookup: { + /* query users to get the friend data */ + from: "users", + localField: "data.uid", //just uid if we remove the workaround above + foreignField: "uid", + as: "result", + let: { + addedAt: "$data.addedAt", //just $addedAt if we remove the workaround above + friendRequestId: "$data.friendRequestId", //just $friendRequestId if we remove the workaround above + }, + pipeline: [ + { + $project: { + _id: false, + uid: true, + friendRequestId: true, + name: true, + discordId: true, + discordAvatar: true, + startedTests: true, + completedTests: true, + timeTyping: true, + xp: true, + streak: true, + personalBests: true, + "inventory.badges": true, + "premium.expirationTimestamp": true, + banned: 1, + lbOptOut: 1, + }, + }, + { + $addFields: { + addedAt: "$$addedAt", + friendRequestId: "$$friendRequestId", + top15: { + $reduce: { + //find highest wpm from time 15 PBs + input: "$personalBests.time.15", + initialValue: {}, + in: { + $cond: [ + { $gte: ["$$this.wpm", "$$value.wpm"] }, + "$$this", + "$$value", + ], + }, + }, + }, + top60: { + $reduce: { + //find highest wpm from time 60 PBs + input: "$personalBests.time.60", + initialValue: {}, + in: { + $cond: [ + { $gte: ["$$this.wpm", "$$value.wpm"] }, + "$$this", + "$$value", + ], + }, + }, + }, + badgeId: { + $ifNull: [ + { + $first: { + $map: { + input: { + $filter: { + input: "$inventory.badges", + as: "badge", + cond: { $eq: ["$$badge.selected", true] }, + }, + }, + as: "selectedBadge", + in: "$$selectedBadge.id", + }, + }, + }, + "$$REMOVE", + ], + }, + isPremium: { + $cond: { + if: { + $or: [ + { $eq: ["$premium.expirationTimestamp", -1] }, + { + $gt: [ + "$premium.expirationTimestamp", + { $toLong: "$$NOW" }, + ], + }, + ], + }, + // oxlint-disable-next-line no-thenable + then: true, + else: "$$REMOVE", + }, + }, + }, + }, + { + $addFields: { + //remove nulls + top15: { $ifNull: ["$top15", "$$REMOVE"] }, + top60: { $ifNull: ["$top60", "$$REMOVE"] }, + badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] }, + addedAt: "$addedAt", + }, + }, + { + $project: { + personalBests: false, + inventory: false, + premium: false, + }, + }, + ], + }, + }, + { + $replaceRoot: { + newRoot: { + $cond: [ + { $gt: [{ $size: "$result" }, 0] }, + { $first: "$result" }, + {}, // empty document fallback, this can happen if the user is not present + ], + }, + }, + }, + ]) + .toArray()) as DBFriend[]; +} + +export async function getFriendsUids(uid: string): Promise { + return Array.from( + new Set( + ( + await getCollection() + .find( + { + $and: [ + { + $or: [{ initiatorUid: uid }, { friendUid: uid }], + status: "accepted", + }, + ], + }, + { projection: { initiatorUid: true, friendUid: true } } + ) + .toArray() + ).flatMap((it) => [it.initiatorUid, it.friendUid]) + ) + ); +} + +export async function createIndicies(): Promise { + //index used for search + await getCollection().createIndex({ initiatorUid: 1 }); + await getCollection().createIndex({ friendUid: 1 }); + + //make sure there is only one friend entry for each friend/creator pair + await getCollection().createIndex({ key: 1 }, { unique: true }); +} diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 42594eb1baf1..304f6cf50d0b 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -9,7 +9,7 @@ import { } from "../init/configuration"; import { addLog } from "./logs"; -import { Collection, ObjectId } from "mongodb"; +import { Collection, Document, ObjectId } from "mongodb"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; import { omit } from "lodash"; import { DBUser, getUsersCollection } from "./user"; @@ -34,28 +34,41 @@ export async function get( language: string, page: number, pageSize: number, - premiumFeaturesEnabled: boolean = false + premiumFeaturesEnabled: boolean = false, + userIds?: string[] ): Promise { if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); } + if (userIds?.length === 0) { + return []; + } const skip = page * pageSize; const limit = pageSize; + const pipeline: Document[] = [{ $skip: skip }, { $limit: limit }]; + + if (userIds !== undefined) { + pipeline.splice(0, 0, { $match: { uid: { $in: userIds } } }); + pipeline.splice(1, 0, { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + }); + } + try { - const preset = await getCollection({ language, mode, mode2 }) - .find() - .sort({ rank: 1 }) - .skip(skip) - .limit(limit) - .toArray(); + let leaderboard = (await getCollection({ language, mode, mode2 }) + .aggregate(pipeline) + .toArray()) as DBLeaderboardEntry[]; if (!premiumFeaturesEnabled) { - return preset.map((it) => omit(it, "isPremium")); + leaderboard = leaderboard.map((it) => omit(it, "isPremium")); } - return preset; + return leaderboard; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (e.error === 175) { @@ -71,19 +84,25 @@ const cachedCounts = new Map(); export async function getCount( mode: string, mode2: string, - language: string + language: string, + userIds?: string[] ): Promise { const key = `${language}_${mode}_${mode2}`; - if (cachedCounts.has(key)) { + if (userIds === undefined && cachedCounts.has(key)) { return cachedCounts.get(key) as number; } else { - const count = await getCollection({ + const lb = await getCollection({ language, mode, mode2, - }).estimatedDocumentCount(); - cachedCounts.set(key, count); - return count; + }); + if (userIds === undefined) { + const count = await lb.estimatedDocumentCount(); + cachedCounts.set(key, count); + return count; + } else { + return lb.countDocuments({ uid: { $in: userIds } }); + } } } @@ -91,14 +110,34 @@ export async function getRank( mode: string, mode2: string, language: string, - uid: string -): Promise { + uid: string, + userIds?: string[] +): Promise { try { - const entry = await getCollection({ language, mode, mode2 }).findOne({ - uid, - }); + if (userIds === undefined) { + const entry = await getCollection({ language, mode, mode2 }).findOne({ + uid, + }); - return entry; + return entry; + } else if (userIds.length === 0) { + return null; + } else { + const entry = await getCollection({ language, mode, mode2 }) + .aggregate([ + { $match: { uid: { $in: userIds } } }, + { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + }, + { $match: { uid } }, + ]) + .toArray(); + + return entry[0] as DBLeaderboardEntry; + } } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (e.error === 175) { diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 17b0615ad30b..2dc2d379c2d4 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -264,6 +264,31 @@ export async function getPartialUser( return results; } +/** + * Get users document only containing requested fields + * @param uids user ids + * @param stack stack description used in the error + * @param fields list of fields + * @returns map of userids with partial DBUser only containing requested fields + */ +/*export async function getPartialUsers( + uids: string[], + stack: string, + fields: K[] +): Promise>> { + const projection = new Map(fields.map((it) => [it, 1])); + const results = await getUsersCollection().find( + { uid: { $in: uids } }, + { projection } + ); + if (results === null) return {}; + + return Object.fromEntries( + (await results.toArray()).map((it) => [it.uid, it]) + ); +} + */ + export async function findByName(name: string): Promise { return ( await getUsersCollection() diff --git a/backend/src/init/redis.ts b/backend/src/init/redis.ts index 8b816d3aa904..5b81a005b6e6 100644 --- a/backend/src/init/redis.ts +++ b/backend/src/init/redis.ts @@ -33,8 +33,17 @@ export type RedisConnectionWithCustomMethods = Redis & { resultsKey: string, minRank: number, maxRank: number, - withScores: string - ) => Promise<[string[], string[]]>; + withScores: string, + userIds: string + ) => Promise<[string[], string[], string, string, string[]]>; //entries, scores(optional), count, min_score(optiona), ranks(optional) + getRank: ( + keyCount: number, + scoresKey: string, + resultsKey: string, + uid: string, + withScores: string, + userIds: string + ) => Promise<[number, string, string, number]>; //rank, score(optional), entry json, friendsRank(optional) purgeResults: ( keyCount: number, uid: string, diff --git a/backend/src/server.ts b/backend/src/server.ts index 7570078a0bd7..d23e0ac87e31 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,6 +17,7 @@ import * as EmailClient from "./init/email-client"; import { init as initFirebaseAdmin } from "./init/firebase-admin"; import { createIndicies as leaderboardDbSetup } from "./dal/leaderboards"; import { createIndicies as blocklistDbSetup } from "./dal/blocklist"; +import { createIndicies as friendsDbSetup } from "./dal/friends"; import { getErrorMessage } from "./utils/error"; async function bootServer(port: number): Promise { @@ -76,6 +77,9 @@ async function bootServer(port: number): Promise { Logger.info("Setting up blocklist indicies..."); await blocklistDbSetup(); + Logger.info("Setting up friends indicies..."); + await friendsDbSetup(); + recordServerVersion(version); } catch (error) { Logger.error("Failed to boot server"); diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 3d2bcfaba517..8515960c2cf2 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -144,7 +144,8 @@ export class WeeklyXpLeaderboard { weeklyXpLeaderboardResultsKey, minRank, maxRank, - "true" + "true", + "" //TODO friends )) as string[][]; if (results === undefined) { diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index c0b5aa57ef2e..4a4eb5f69850 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -112,31 +112,48 @@ export class DailyLeaderboard { page: number, pageSize: number, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], - premiumFeaturesEnabled: boolean - ): Promise { + premiumFeaturesEnabled: boolean, + userIds?: string[] + ): Promise<{ + entries: LeaderboardEntry[]; + count: number; + minWpm: number; + } | null> { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { - return []; + return null; } if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); } + if (userIds?.length === 0) { + return { entries: [], count: 0, minWpm: 0 }; + } + + const isFriends = userIds !== undefined; const minRank = page * pageSize; const maxRank = minRank + pageSize - 1; const { leaderboardScoresKey, leaderboardResultsKey } = this.getTodaysLeaderboardKeys(); - const [results, _] = await connection.getResults( - 2, - leaderboardScoresKey, - leaderboardResultsKey, - minRank, - maxRank, - "false" - ); + const [results, _, count, [_uid, minScore], ranks] = + await connection.getResults( + 2, + leaderboardScoresKey, + leaderboardResultsKey, + minRank, + maxRank, + "false", + userIds?.join(",") ?? "" + ); + + const minWpm = + minScore !== undefined + ? parseInt(minScore.toString()?.slice(1, 6)) / 100 + : 0; if (results === undefined) { throw new Error( @@ -144,7 +161,7 @@ export class DailyLeaderboard { ); } - const resultsWithRanks: LeaderboardEntry[] = results.map( + let resultsWithRanks: LeaderboardEntry[] = results.map( (resultJSON, index) => { try { const parsed = parseJsonWithSchema( @@ -154,7 +171,10 @@ export class DailyLeaderboard { return { ...parsed, - rank: minRank + index + 1, + rank: isFriends + ? new Number(ranks[index]).valueOf() + 1 + : minRank + index + 1, + friendsRank: isFriends ? minRank + index + 1 : undefined, }; } catch (error) { throw new Error( @@ -167,61 +187,38 @@ export class DailyLeaderboard { ); if (!premiumFeaturesEnabled) { - return resultsWithRanks.map((it) => omit(it, "isPremium")); + resultsWithRanks = resultsWithRanks.map((it) => omit(it, "isPremium")); } - return resultsWithRanks; - } - - public async getMinWpm( - dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] - ): Promise { - const connection = RedisClient.getConnection(); - if (!connection || !dailyLeaderboardsConfig.enabled) { - return 0; - } - - const { leaderboardScoresKey } = this.getTodaysLeaderboardKeys(); - - const [_uid, minScore] = (await connection.zrange( - leaderboardScoresKey, - 0, - 0, - "WITHSCORES" - )) as [string, string]; - - const minWpm = - minScore !== undefined ? parseInt(minScore?.slice(1, 6)) / 100 : 0; - - return minWpm; + return { entries: resultsWithRanks, count: parseInt(count), minWpm }; } public async getRank( uid: string, - dailyLeaderboardsConfig: Configuration["dailyLeaderboards"] + dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], + userIds?: string[] ): Promise { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { throw new Error("Redis connection is unavailable"); } + if (userIds?.length === 0) { + return null; + } const { leaderboardScoresKey, leaderboardResultsKey } = this.getTodaysLeaderboardKeys(); - const redisExecResult = (await connection - .multi() - .zrevrank(leaderboardScoresKey, uid) - .zcard(leaderboardScoresKey) - .hget(leaderboardResultsKey, uid) - .exec()) as [ - [null, number | null], - [null, number | null], - [null, string | null] - ]; - - const [[, rank], [, _count], [, result]] = redisExecResult; + const [rank, _score, result, friendsRank] = await connection.getRank( + 2, + leaderboardScoresKey, + leaderboardResultsKey, + uid, + "false", + userIds?.join(",") ?? "" + ); - if (rank === null) { + if (rank === null || rank === undefined) { return null; } @@ -232,6 +229,7 @@ export class DailyLeaderboard { RedisDailyLeaderboardEntrySchema ), rank: rank + 1, + friendsRank: friendsRank !== undefined ? friendsRank + 1 : undefined, }; } catch (error) { throw new Error( @@ -241,17 +239,6 @@ export class DailyLeaderboard { ); } } - - public async getCount(): Promise { - const connection = RedisClient.getConnection(); - if (!connection) { - throw new Error("Redis connection is unavailable"); - } - - const { leaderboardScoresKey } = this.getTodaysLeaderboardKeys(); - - return connection.zcard(leaderboardScoresKey); - } } export async function purgeUserFromDailyLeaderboards( diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index 2ca2ea8f31dc..4cb4a0a800bf 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -45,7 +45,7 @@ async function handleDailyLeaderboardResults( false ); - if (results.length === 0) { + if (results === null || results.entries.length === 0) { return; } @@ -55,7 +55,7 @@ async function handleDailyLeaderboardResults( mail: MonkeyMail[]; }[] = []; - results.forEach((entry) => { + results.entries.forEach((entry) => { const rank = entry.rank ?? maxResults; const wpm = Math.round(entry.wpm); @@ -96,7 +96,7 @@ async function handleDailyLeaderboardResults( await addToInboxBulk(mailEntries, inboxConfig); } - const topResults = results.slice( + const topResults = results.entries.slice( 0, dailyLeaderboardsConfig.topResultsToAnnounce ); diff --git a/frontend/src/html/pages/leaderboards.html b/frontend/src/html/pages/leaderboards.html index 1041691f3d0d..455c30847c14 100644 --- a/frontend/src/html/pages/leaderboards.html +++ b/frontend/src/html/pages/leaderboards.html @@ -46,9 +46,10 @@

Something went wrong

- +
+ + }
# name @@ -173,6 +174,13 @@ daily + + ${entry.friendsRank ?? ""} ${ entry.rank === 1 ? '' : entry.rank - }
@@ -532,6 +548,12 @@ function fillTable(): void { const table = $(".page.pageLeaderboards table tbody"); table.empty(); + if (state.friendsOnly) { + table.parent().addClass("friendsOnly"); + } else { + table.parent().removeClass("friendsOnly"); + } + $(".page.pageLeaderboards table thead").addClass("hidden"); if (state.type === "allTime" || state.type === "daily") { $(".page.pageLeaderboards table thead.allTimeAndDaily").removeClass( @@ -647,9 +669,12 @@ function fillUser(): void { } const userData = state.userData; - const percentile = (userData.rank / state.count) * 100; + const rank = state.friendsOnly + ? (userData.friendsRank as number) + : userData.rank; + const percentile = (rank / state.count) * 100; let percentileString = `Top ${percentile.toFixed(2)}%`; - if (userData.rank === 1) { + if (rank === 1) { percentileString = "GOAT"; } @@ -681,9 +706,7 @@ function fillUser(): void { const html = `
${ - userData.rank === 1 - ? '' - : userData.rank + rank === 1 ? '' : rank }
You (${percentileString})
@@ -736,6 +759,8 @@ function fillUser(): void { } const userData = state.userData; + + //todo use friend rank const percentile = (userData.rank / state.count) * 100; let percentileString = `Top ${percentile.toFixed(2)}%`; if (userData.rank === 1) { @@ -883,6 +908,7 @@ function updateContent(): void { function updateSideButtons(): void { updateTypeButtons(); + updateFriendsOnlyButton(); updateModeButtons(); updateLanguageButtons(); } @@ -893,6 +919,26 @@ function updateTypeButtons(): void { el.find(`button[data-type=${state.type}]`).addClass("active"); } +function updateFriendsOnlyButton(): void { + const friendsOnlyGroup = $( + ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons" + ); + if (ServerConfiguration.get()?.friends.enabled ?? false) { + friendsOnlyGroup.removeClass("hidden"); + } else { + friendsOnlyGroup.addClass("hidden"); + } + + const friendsOnlyButton = $( + ".page.pageLeaderboards .buttonGroup.friendsOnlyButtons .friendsOnly" + ); + if (state.friendsOnly) { + friendsOnlyButton.addClass("active"); + } else { + friendsOnlyButton.removeClass("active"); + } +} + function updateModeButtons(): void { if (state.type !== "allTime" && state.type !== "daily") { $(".page.pageLeaderboards .buttonGroup.modeButtons").addClass("hidden"); @@ -1246,6 +1292,9 @@ function updateGetParameters(): void { } params.page = state.page + 1; + if (state.friendsOnly) { + params.friendsOnly = true; + } page.setUrlParams(params); @@ -1262,6 +1311,8 @@ function readGetParameters(params?: UrlParameter): void { state.type = params.type; } + state.friendsOnly = params.friendsOnly ?? false; + if (state.type === "allTime") { if (params.mode2 !== undefined) { state.mode2 = params.mode2 as AllTimeState["mode2"]; @@ -1353,6 +1404,18 @@ $(".page.pageLeaderboards .buttonGroup.typeButtons").on( updateGetParameters(); } ); +$(".page.pageLeaderboards .buttonGroup.friendsOnlyButtons").on( + "click", + "button", + () => { + state.friendsOnly = !state.friendsOnly; + void requestData(); + updateTitle(); + updateSideButtons(); + updateContent(); + updateGetParameters(); + } +); $(".page.pageLeaderboards .buttonGroup.modeButtons").on( "click", diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts new file mode 100644 index 000000000000..76c2a6ff5eab --- /dev/null +++ b/packages/contracts/src/friends.ts @@ -0,0 +1,154 @@ +import { initContract } from "@ts-rest/core"; + +import { + FriendRequestSchema, + FriendRequestStatusSchema, + FriendRequestTypeSchema, + FriendSchema, +} from "@monkeytype/schemas/friends"; +import { z } from "zod"; +import { + CommonResponses, + meta, + MonkeyResponseSchema, + responseWithData, +} from "./util/api"; +import { IdSchema } from "@monkeytype/schemas/util"; + +const c = initContract(); + +export const GetFriendRequestsResponseSchema = responseWithData( + z.array(FriendRequestSchema) +); +export type GetFriendRequestsResponse = z.infer< + typeof GetFriendRequestsResponseSchema +>; + +export const GetFriendRequestsQuerySchema = z.object({ + status: z + .array(FriendRequestStatusSchema) + .or(FriendRequestStatusSchema.transform((it) => [it])) + .optional(), + type: z + .array(FriendRequestTypeSchema) + .or(FriendRequestTypeSchema.transform((it) => [it])) + .optional(), +}); +export type GetFriendRequestsQuery = z.infer< + typeof GetFriendRequestsQuerySchema +>; + +export const CreateFriendRequestRequestSchema = FriendRequestSchema.pick({ + friendName: true, +}); +export type CreateFriendRequestRequest = z.infer< + typeof CreateFriendRequestRequestSchema +>; + +export const CreateFriendRequestResponseSchema = + responseWithData(FriendRequestSchema); +export type CreateFriendRequestResponse = z.infer< + typeof CreateFriendRequestResponseSchema +>; + +export const IdPathParamsSchema = z.object({ + id: IdSchema, +}); +export type IdPathParams = z.infer; + +export const UpdateFriendRequestsRequestSchema = z.object({ + status: FriendRequestStatusSchema.exclude(["pending"]), +}); +export type UpdateFriendRequestsRequest = z.infer< + typeof UpdateFriendRequestsRequestSchema +>; + +export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema)); +export type GetFriendsResponse = z.infer; + +export const friendsContract = c.router( + { + getRequests: { + summary: "get friend requests", + description: "Get friend requests of the current user", + method: "GET", + path: "/requests", + query: GetFriendRequestsQuerySchema.strict(), + responses: { + 200: GetFriendRequestsResponseSchema, + }, + metadata: meta({ + rateLimit: "friendRequestsGet", + }), + }, + createRequest: { + summary: "create friend request", + description: "Request a user to become a friend", + method: "POST", + path: "/requests", + body: CreateFriendRequestRequestSchema.strict(), + responses: { + 200: CreateFriendRequestResponseSchema, + 404: MonkeyResponseSchema.describe("FriendUid unknown"), + 409: MonkeyResponseSchema.describe( + "Duplicate friend, blocked or max friends reached" + ), + }, + metadata: meta({ + rateLimit: "friendRequestsCreate", + }), + }, + deleteRequest: { + summary: "delete friend request", + description: "Delete a friend request", + method: "DELETE", + path: "/requests/:id", + pathParams: IdPathParamsSchema.strict(), + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: meta({ + rateLimit: "friendRequestsDelete", + }), + }, + updateRequest: { + summary: "update friend request", + description: "Update a friend request status", + method: "PATCH", + path: "/requests/:id", + pathParams: IdPathParamsSchema.strict(), + body: UpdateFriendRequestsRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: meta({ + rateLimit: "friendRequestsUpdate", + }), + }, + getFriends: { + summary: "get friends", + description: "get friends list", + method: "GET", + path: "/", + responses: { + 200: GetFriendsResponseSchema, + }, + metadata: meta({ + rateLimit: "friendGet", + }), + }, + }, + { + pathPrefix: "/friends", + strictStatusCodes: true, + metadata: meta({ + openApiTags: "friends", + requireConfiguration: { + path: "friends.enabled", + invalidMessage: "Friends are not available at this time.", + }, + }), + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3f88073b1bf0..06e0b5fac969 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -12,6 +12,7 @@ import { devContract } from "./dev"; import { usersContract } from "./users"; import { quotesContract } from "./quotes"; import { webhooksContract } from "./webhooks"; +import { friendsContract } from "./friends"; const c = initContract(); @@ -29,6 +30,7 @@ export const contract = c.router({ users: usersContract, quotes: quotesContract, webhooks: webhooksContract, + friends: friendsContract, }); /** diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts index 9e8fab615bd7..255780b09dbf 100644 --- a/packages/contracts/src/leaderboards.ts +++ b/packages/contracts/src/leaderboards.ts @@ -24,9 +24,16 @@ const PaginationQuerySchema = z.object({ page: z.number().int().safe().nonnegative().default(0), pageSize: z.number().int().safe().positive().min(10).max(200).default(50), }); - export type PaginationQuery = z.infer; +const FriendsOnlyQuerySchema = z.object({ + friendsOnly: z + .boolean() + .optional() + .describe("include only users from your friends list, defaults to false."), +}); +export type FriendsOnlyQuery = z.infer; + const LeaderboardResponseSchema = z.object({ count: z.number().int().nonnegative(), pageSize: z.number().int().positive(), @@ -36,7 +43,7 @@ const LeaderboardResponseSchema = z.object({ export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge( PaginationQuerySchema -); +).merge(FriendsOnlyQuerySchema); export type GetLeaderboardQuery = z.infer; export const GetLeaderboardResponseSchema = responseWithData( @@ -50,7 +57,9 @@ export type GetLeaderboardResponse = z.infer< //-------------------------------------------------------------------------- -export const GetLeaderboardRankQuerySchema = LanguageAndModeQuerySchema; +export const GetLeaderboardRankQuerySchema = LanguageAndModeQuerySchema.merge( + FriendsOnlyQuerySchema +); export type GetLeaderboardRankQuery = z.infer< typeof GetLeaderboardRankQuerySchema >; @@ -65,15 +74,16 @@ export type GetLeaderboardRankResponse = z.infer< export const DailyLeaderboardQuerySchema = LanguageAndModeQuerySchema.extend({ daysBefore: z.literal(1).optional(), -}); +}).merge(FriendsOnlyQuerySchema); export type DailyLeaderboardQuery = z.infer; export const GetDailyLeaderboardQuerySchema = DailyLeaderboardQuerySchema.merge( PaginationQuerySchema -); +).merge(FriendsOnlyQuerySchema); export type GetDailyLeaderboardQuery = z.infer< typeof GetDailyLeaderboardQuerySchema >; + export const GetDailyLeaderboardResponseSchema = responseWithData( LeaderboardResponseSchema.extend({ entries: z.array(LeaderboardEntrySchema), diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index 205e3115caa8..0701b80ffafa 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -361,6 +361,31 @@ export const limits = { window: "second", max: 1, }, + + friendRequestsGet: { + window: "hour", + max: 60, + }, + + friendRequestsCreate: { + window: "hour", + max: 60, + }, + + friendRequestsDelete: { + window: "hour", + max: 60, + }, + + friendRequestsUpdate: { + window: "hour", + max: 60, + }, + + friendGet: { + window: "hour", + max: 60, + }, } satisfies Record; export type RateLimiterId = keyof typeof limits; diff --git a/packages/contracts/src/util/api.ts b/packages/contracts/src/util/api.ts index 7697b3e54a66..1d42ab160ccd 100644 --- a/packages/contracts/src/util/api.ts +++ b/packages/contracts/src/util/api.ts @@ -15,7 +15,8 @@ export type OpenApiTag = | "development" | "users" | "quotes" - | "webhooks"; + | "webhooks" + | "friends"; export type PermissionId = | "quoteMod" diff --git a/packages/schemas/src/configuration.ts b/packages/schemas/src/configuration.ts index 1d4b404f23e5..55b645567ab3 100644 --- a/packages/schemas/src/configuration.ts +++ b/packages/schemas/src/configuration.ts @@ -123,5 +123,9 @@ export const ConfigurationSchema = z.object({ xpRewardBrackets: z.array(RewardBracketSchema), }), }), + friends: z.object({ + enabled: z.boolean(), + maxFriendsPerUser: z.number().int().nonnegative(), + }), }); export type Configuration = z.infer; diff --git a/packages/schemas/src/friends.ts b/packages/schemas/src/friends.ts new file mode 100644 index 000000000000..cf884d726ae6 --- /dev/null +++ b/packages/schemas/src/friends.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { IdSchema } from "./util"; +import { UserSchema } from "./users"; +import { PersonalBestSchema } from "./shared"; + +export const FriendRequestStatusSchema = z.enum([ + "pending", + "accepted", + "blocked", +]); +export type FriendRequestStatus = z.infer; + +export const FriendRequestTypeSchema = z.enum(["incoming", "outgoing"]); +export type FriendRequestType = z.infer; + +export const FriendRequestSchema = z.object({ + _id: IdSchema, + initiatorUid: IdSchema, + initiatorName: z.string(), + friendUid: IdSchema, + friendName: z.string(), + addedAt: z.number().int().nonnegative(), + status: FriendRequestStatusSchema, +}); + +export type FriendRequest = z.infer; + +export const FriendSchema = UserSchema.pick({ + uid: true, + name: true, + discordId: true, + discordAvatar: true, + startedTests: true, + completedTests: true, + timeTyping: true, + xp: true, + streak: true, + banned: true, + lbOptOut: true, +}).extend({ + addedAt: z.number().int().nonnegative().optional(), + friendRequestId: IdSchema.optional(), + top15: PersonalBestSchema.optional(), + top60: PersonalBestSchema.optional(), + badgeId: z.number().int().optional(), + isPremium: z.boolean().optional(), +}); + +export type Friend = z.infer; diff --git a/packages/schemas/src/leaderboards.ts b/packages/schemas/src/leaderboards.ts index 9c299ee29ce5..5eb54e361fe8 100644 --- a/packages/schemas/src/leaderboards.ts +++ b/packages/schemas/src/leaderboards.ts @@ -11,6 +11,12 @@ export const LeaderboardEntrySchema = z.object({ discordId: z.string().optional(), discordAvatar: z.string().optional(), rank: z.number().nonnegative().int(), + friendsRank: z + .number() + .nonnegative() + .int() + .optional() + .describe("only available on friendsOnly leaderboard"), badgeId: z.number().int().optional(), isPremium: z.boolean().optional(), }); @@ -18,6 +24,7 @@ export type LeaderboardEntry = z.infer; export const RedisDailyLeaderboardEntrySchema = LeaderboardEntrySchema.omit({ rank: true, + friendsRank: true, }); export type RedisDailyLeaderboardEntry = z.infer< typeof RedisDailyLeaderboardEntrySchema