rename friends to connections

This commit is contained in:
Christian Fehmer 2025-09-10 13:56:29 +02:00
parent a77186697e
commit a65a81b464
No known key found for this signature in database
GPG key ID: FE53784A69964062
21 changed files with 417 additions and 392 deletions

View file

@ -9,61 +9,64 @@ import {
} from "vitest";
import { ObjectId } from "mongodb";
import * as FriendsDal from "../../../src/dal/friends";
import { createConnection as createFriend } from "../../__testData__/connections";
import * as ConnectionsDal from "../../../src/dal/connections";
import { createConnection } from "../../__testData__/connections";
describe("FriendsDal", () => {
describe("ConnectionsDal", () => {
beforeAll(async () => {
await FriendsDal.createIndicies();
await ConnectionsDal.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({});
const initOne = await createConnection({ initiatorUid: uid });
const initTwo = await createConnection({ initiatorUid: uid });
const friendOne = await createConnection({ friendUid: uid });
const _decoy = await createConnection({});
//WHEN / THEM
expect(
await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid })
await ConnectionsDal.getConnections({
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({
const initAccepted = await createConnection({
initiatorUid: uid,
status: "accepted",
});
const _initPending = await createFriend({
const _initPending = await createConnection({
initiatorUid: uid,
status: "pending",
});
const initBlocked = await createFriend({
const initBlocked = await createConnection({
initiatorUid: uid,
status: "blocked",
});
const friendAccepted = await createFriend({
const friendAccepted = await createConnection({
friendUid: uid,
status: "accepted",
});
const _friendPending = await createFriend({
const _friendPending = await createConnection({
friendUid: uid,
status: "pending",
});
const _decoy = await createFriend({ status: "accepted" });
const _decoy = await createConnection({ status: "accepted" });
//WHEN / THEN
expect(
await FriendsDal.getRequests({
await ConnectionsDal.getConnections({
initiatorUid: uid,
friendUid: uid,
status: ["accepted", "blocked"],
@ -85,17 +88,17 @@ describe("FriendsDal", () => {
it("should fail creating duplicates", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createFriend({
const first = await createConnection({
initiatorUid: uid,
});
//WHEN/THEN
await expect(
createFriend({
createConnection({
initiatorUid: first.friendUid,
friendUid: uid,
})
).rejects.toThrow("Duplicate friend or blocked");
).rejects.toThrow("Duplicate connection or blocked");
});
it("should create", async () => {
@ -104,7 +107,7 @@ describe("FriendsDal", () => {
const friendUid = new ObjectId().toHexString();
//WHEN
const created = await FriendsDal.create(
const created = await ConnectionsDal.create(
{ uid, name: "Bob" },
{ uid: friendUid, name: "Kevin" },
2
@ -123,15 +126,15 @@ describe("FriendsDal", () => {
});
});
it("should fail if maximum friends are reached", async () => {
it("should fail if maximum connections are reached", async () => {
//GIVEN
const initiatorUid = new ObjectId().toHexString();
await createFriend({ initiatorUid });
await createFriend({ initiatorUid });
await createConnection({ initiatorUid });
await createConnection({ initiatorUid });
//WHEN / THEM
await expect(createFriend({ initiatorUid }, 2)).rejects.toThrow(
"Maximum number of friends reached\nStack: create friend request"
await expect(createConnection({ initiatorUid }, 2)).rejects.toThrow(
"Maximum number of connections reached\nStack: create connection request"
);
});
});
@ -139,36 +142,44 @@ describe("FriendsDal", () => {
it("should update the status", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createFriend({
const first = await createConnection({
friendUid: uid,
});
const second = await createFriend({
const second = await createConnection({
friendUid: uid,
});
//WHEN
await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted");
await ConnectionsDal.updateStatus(
uid,
first._id.toHexString(),
"accepted"
);
//THEN
expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual([
expect(await ConnectionsDal.getConnections({ friendUid: uid })).toEqual([
{ ...first, status: "accepted" },
second,
]);
//can update twice to the same status
await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted");
await ConnectionsDal.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({
const first = await createConnection({
initiatorUid: uid,
});
//WHEN / THEN
await expect(
FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted")
).rejects.toThrow("Friend not found");
ConnectionsDal.updateStatus(uid, first._id.toHexString(), "accepted")
).rejects.toThrow("Connection not found");
});
});
@ -176,81 +187,85 @@ describe("FriendsDal", () => {
it("should delete by initiator", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createFriend({
const first = await createConnection({
initiatorUid: uid,
});
const second = await createFriend({
const second = await createConnection({
initiatorUid: uid,
});
//WHEN
await FriendsDal.deleteById(uid, first._id.toHexString());
await ConnectionsDal.deleteById(uid, first._id.toHexString());
//THEN
expect(await FriendsDal.getRequests({ initiatorUid: uid })).toStrictEqual(
[second]
);
expect(
await ConnectionsDal.getConnections({ initiatorUid: uid })
).toStrictEqual([second]);
});
it("should delete by friend", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createFriend({
const first = await createConnection({
friendUid: uid,
});
const second = await createFriend({
const second = await createConnection({
friendUid: uid,
status: "accepted",
});
//WHEN
await FriendsDal.deleteById(uid, first._id.toHexString());
await ConnectionsDal.deleteById(uid, first._id.toHexString());
//THEN
expect(
await FriendsDal.getRequests({ initiatorUid: second.initiatorUid })
await ConnectionsDal.getConnections({
initiatorUid: second.initiatorUid,
})
).toStrictEqual([second]);
});
it("should fail if uid does not match", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createFriend({
const first = await createConnection({
initiatorUid: uid,
});
//WHEN / THEN
await expect(
FriendsDal.deleteById("Bob", first._id.toHexString())
ConnectionsDal.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({
const myRequestWasBlocked = await createConnection({
initiatorName: uid,
status: "blocked",
});
//WHEN / THEN
await expect(
FriendsDal.deleteById(uid, myRequestWasBlocked._id.toHexString())
ConnectionsDal.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({
const myBlockedUser = await createConnection({
friendUid: uid,
status: "blocked",
});
//WHEN
await FriendsDal.deleteById(uid, myBlockedUser._id.toHexString());
await ConnectionsDal.deleteById(uid, myBlockedUser._id.toHexString());
//THEN
expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual([]);
expect(await ConnectionsDal.getConnections({ friendUid: uid })).toEqual(
[]
);
});
});
@ -258,21 +273,26 @@ describe("FriendsDal", () => {
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({});
const _initOne = await createConnection({ initiatorUid: uid });
const _initTwo = await createConnection({ initiatorUid: uid });
const _friendOne = await createConnection({ friendUid: uid });
const decoy = await createConnection({});
//WHEN
await FriendsDal.deleteByUid(uid);
await ConnectionsDal.deleteByUid(uid);
//THEN
expect(
await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid })
await ConnectionsDal.getConnections({
initiatorUid: uid,
friendUid: uid,
})
).toEqual([]);
expect(
await FriendsDal.getRequests({ initiatorUid: decoy.initiatorUid })
await ConnectionsDal.getConnections({
initiatorUid: decoy.initiatorUid,
})
).toEqual([decoy]);
});
});
@ -280,26 +300,29 @@ describe("FriendsDal", () => {
it("should update the name", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const initOne = await createFriend({
const initOne = await createConnection({
initiatorUid: uid,
initiatorName: "Bob",
});
const initTwo = await createFriend({
const initTwo = await createConnection({
initiatorUid: uid,
initiatorName: "Bob",
});
const friendOne = await createFriend({
const friendOne = await createConnection({
friendUid: uid,
friendName: "Bob",
});
const decoy = await createFriend({});
const decoy = await createConnection({});
//WHEN
await FriendsDal.updateName(uid, "King Bob");
await ConnectionsDal.updateName(uid, "King Bob");
//THEN
expect(
await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid })
await ConnectionsDal.getConnections({
initiatorUid: uid,
friendUid: uid,
})
).toEqual([
{ ...initOne, initiatorName: "King Bob" },
{ ...initTwo, initiatorName: "King Bob" },
@ -307,7 +330,9 @@ describe("FriendsDal", () => {
]);
expect(
await FriendsDal.getRequests({ initiatorUid: decoy.initiatorUid })
await ConnectionsDal.getConnections({
initiatorUid: decoy.initiatorUid,
})
).toEqual([decoy]);
});
});

View file

@ -1,11 +1,11 @@
import { ObjectId } from "mongodb";
import * as FriendsDal from "../../src/dal/friends";
import * as ConnectionsDal from "../../src/dal/connections";
export async function createConnection(
data: Partial<FriendsDal.DBFriendRequest>,
data: Partial<ConnectionsDal.DBConnection>,
maxPerUser = 25
): Promise<FriendsDal.DBFriendRequest> {
const result = await FriendsDal.create(
): Promise<ConnectionsDal.DBConnection> {
const result = await ConnectionsDal.create(
{
uid: data.initiatorUid ?? new ObjectId().toHexString(),
name: data.initiatorName ?? "user" + new ObjectId().toHexString(),
@ -16,7 +16,7 @@ export async function createConnection(
},
maxPerUser
);
await FriendsDal.getCollection().updateOne(
await ConnectionsDal.getCollection().updateOne(
{ _id: result._id },
{ $set: data }
);

View file

@ -5,7 +5,7 @@ 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 ConnectionsDal from "../../../src/dal/connections";
import * as UserDal from "../../../src/dal/user";
const mockApp = request(app);
@ -13,24 +13,24 @@ const configuration = Configuration.getCachedConfiguration();
const uid = new ObjectId().toHexString();
const mockAuth = mockBearerAuthentication(uid);
describe("FriendsController", () => {
describe("ConnectionsController", () => {
beforeEach(async () => {
await enableFriendsEndpoints(true);
await enablleConnectionsEndpoints(true);
vi.useFakeTimers();
vi.setSystemTime(1000);
mockAuth.beforeEach();
});
describe("get friend requests", () => {
const getFriendsMock = vi.spyOn(FriendsDal, "getRequests");
describe("get connections", () => {
const getConnectionsMock = vi.spyOn(ConnectionsDal, "getConnections");
beforeEach(() => {
getFriendsMock.mockClear();
getConnectionsMock.mockClear();
});
it("should get for the current user", async () => {
//GIVEN
const friend: FriendsDal.DBFriendRequest = {
const friend: ConnectionsDal.DBConnection = {
_id: new ObjectId(),
addedAt: 42,
initiatorUid: new ObjectId().toHexString(),
@ -41,11 +41,11 @@ describe("FriendsController", () => {
key: "key",
};
getFriendsMock.mockResolvedValue([friend]);
getConnectionsMock.mockResolvedValue([friend]);
//WHEN
const { body } = await mockApp
.get("/friends/requests")
.get("/connections")
.set("Authorization", `Bearer ${uid}`)
.expect(200);
@ -53,7 +53,7 @@ describe("FriendsController", () => {
expect(body.data).toEqual([
{ ...friend, _id: friend._id.toHexString(), key: undefined },
]);
expect(getFriendsMock).toHaveBeenCalledWith({
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
friendUid: uid,
});
@ -61,17 +61,17 @@ describe("FriendsController", () => {
it("should filter by status", async () => {
//GIVEN
getFriendsMock.mockResolvedValue([]);
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/friends/requests")
.get("/connections")
.query({ status: "accepted" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getFriendsMock).toHaveBeenCalledWith({
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
friendUid: uid,
status: ["accepted"],
@ -80,17 +80,17 @@ describe("FriendsController", () => {
it("should filter by multiple status", async () => {
//GIVEN
getFriendsMock.mockResolvedValue([]);
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/friends/requests")
.get("/connections")
.query({ status: ["accepted", "blocked"] })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getFriendsMock).toHaveBeenCalledWith({
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
friendUid: uid,
status: ["accepted", "blocked"],
@ -99,67 +99,67 @@ describe("FriendsController", () => {
it("should filter by type incoming", async () => {
//GIVEN
getFriendsMock.mockResolvedValue([]);
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/friends/requests")
.get("/connections")
.query({ type: "incoming" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getFriendsMock).toHaveBeenCalledWith({
expect(getConnectionsMock).toHaveBeenCalledWith({
friendUid: uid,
});
});
it("should filter by type outgoing", async () => {
//GIVEN
getFriendsMock.mockResolvedValue([]);
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/friends/requests")
.get("/connections")
.query({ type: "outgoing" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getFriendsMock).toHaveBeenCalledWith({
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
});
});
it("should filter by multiple types", async () => {
//GIVEN
getFriendsMock.mockResolvedValue([]);
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/friends/requests")
.get("/connections")
.query({ type: ["incoming", "outgoing"] })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getFriendsMock).toHaveBeenCalledWith({
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
friendUid: uid,
});
});
it("should fail if friends endpoints are disabled", async () => {
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp.get("/friends/requests").set("Authorization", `Bearer ${uid}`)
mockApp.get("/connections").set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.get("/friends/requests").expect(401);
await mockApp.get("/connections").expect(401);
});
it("should fail for unknown query parameter", async () => {
const { body } = await mockApp
.get("/friends/requests")
.get("/connections")
.query({ extra: "yes" })
.set("Authorization", `Bearer ${uid}`)
.expect(422);
@ -174,7 +174,7 @@ describe("FriendsController", () => {
describe("create friend request", () => {
const getUserByNameMock = vi.spyOn(UserDal, "getUserByName");
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
const createUserMock = vi.spyOn(FriendsDal, "create");
const createUserMock = vi.spyOn(ConnectionsDal, "create");
beforeEach(() => {
[getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) =>
@ -189,7 +189,7 @@ describe("FriendsController", () => {
getUserByNameMock.mockResolvedValue(myFriend as any);
getPartialUserMock.mockResolvedValue(me as any);
const result: FriendsDal.DBFriendRequest = {
const result: ConnectionsDal.DBConnection = {
_id: new ObjectId(),
addedAt: 42,
initiatorUid: me.uid,
@ -203,7 +203,7 @@ describe("FriendsController", () => {
//WHEN
const { body } = await mockApp
.post("/friends/requests")
.post("/connections")
.send({ friendName: "Kevin" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
@ -219,11 +219,15 @@ describe("FriendsController", () => {
status: "pending",
});
expect(getUserByNameMock).toHaveBeenCalledWith("Kevin", "create friend");
expect(getPartialUserMock).toHaveBeenCalledWith(uid, "create friend", [
"uid",
"name",
]);
expect(getUserByNameMock).toHaveBeenCalledWith(
"Kevin",
"create connection"
);
expect(getPartialUserMock).toHaveBeenCalledWith(
uid,
"create connection",
["uid", "name"]
);
expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100);
});
@ -236,7 +240,7 @@ describe("FriendsController", () => {
//WHEN
const { body } = await mockApp
.post("/friends/requests")
.post("/connections")
.send({ friendName: "Bob" })
.set("Authorization", `Bearer ${uid}`)
.expect(400);
@ -248,7 +252,7 @@ describe("FriendsController", () => {
it("should fail without mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/friends/requests")
.post("/connections")
.send({})
.set("Authorization", `Bearer ${uid}`)
.expect(422);
@ -262,7 +266,7 @@ describe("FriendsController", () => {
it("should fail with extra properties", async () => {
//WHEN
const { body } = await mockApp
.post("/friends/requests")
.post("/connections")
.send({ friendName: "1", extra: "value" })
.set("Authorization", `Bearer ${uid}`)
.expect(422);
@ -274,22 +278,22 @@ describe("FriendsController", () => {
});
});
it("should fail if friends endpoints are disabled", async () => {
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp
.post("/friends/requests")
.post("/connections")
.send({ friendName: "1" })
.set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.post("/friends/requests").expect(401);
await mockApp.post("/connections").expect(401);
});
});
describe("delete friend request", () => {
const deleteByIdMock = vi.spyOn(FriendsDal, "deleteById");
const deleteByIdMock = vi.spyOn(ConnectionsDal, "deleteById");
beforeEach(() => {
deleteByIdMock.mockClear().mockResolvedValue();
@ -298,28 +302,26 @@ describe("FriendsController", () => {
it("should delete by id", async () => {
//WHEN
await mockApp
.delete("/friends/requests/1")
.delete("/connections/1")
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(deleteByIdMock).toHaveBeenCalledWith(uid, "1");
});
it("should fail if friends endpoints are disabled", async () => {
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp
.delete("/friends/requests/1")
.set("Authorization", `Bearer ${uid}`)
mockApp.delete("/connections/1").set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.delete("/friends/requests/1").expect(401);
await mockApp.delete("/connections/1").expect(401);
});
});
describe("update friend request", () => {
const updateStatusMock = vi.spyOn(FriendsDal, "updateStatus");
const updateStatusMock = vi.spyOn(ConnectionsDal, "updateStatus");
beforeEach(() => {
updateStatusMock.mockClear().mockResolvedValue();
@ -328,7 +330,7 @@ describe("FriendsController", () => {
it("should accept", async () => {
//WHEN
await mockApp
.patch("/friends/requests/1")
.patch("/connections/1")
.send({ status: "accepted" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
@ -339,7 +341,7 @@ describe("FriendsController", () => {
it("should block", async () => {
//WHEN
await mockApp
.patch("/friends/requests/1")
.patch("/connections/1")
.send({ status: "blocked" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
@ -350,7 +352,7 @@ describe("FriendsController", () => {
it("should fail for invalid status", async () => {
const { body } = await mockApp
.patch("/friends/requests/1")
.patch("/connections/1")
.send({ status: "invalid" })
.set("Authorization", `Bearer ${uid}`)
.expect(422);
@ -362,10 +364,10 @@ describe("FriendsController", () => {
],
});
});
it("should fail if friends endpoints are disabled", async () => {
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp
.patch("/friends/requests/1")
.patch("/connections/1")
.send({ status: "accepted" })
.set("Authorization", `Bearer ${uid}`)
);
@ -373,14 +375,14 @@ describe("FriendsController", () => {
it("should fail without authentication", async () => {
await mockApp
.patch("/friends/requests/1")
.patch("/connections/1")
.send({ status: "accepted" })
.expect(401);
});
});
});
async function enableFriendsEndpoints(enabled: boolean): Promise<void> {
async function enablleConnectionsEndpoints(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
connections: { enabled },
});
@ -390,7 +392,7 @@ async function enableFriendsEndpoints(enabled: boolean): Promise<void> {
);
}
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
await enableFriendsEndpoints(false);
await enablleConnectionsEndpoints(false);
const { body } = await call.expect(503);
expect(body.message).toEqual("Friends are not available at this time.");
expect(body.message).toEqual("Connections are not available at this time.");
}

View file

@ -36,7 +36,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 * as ConnectionsDal from "../../../src/dal/connections";
import { pb } from "../../__testData__/users";
import { SuperTest } from "supertest";
@ -626,7 +626,7 @@ describe("user controller test", () => {
"purgeUserFromXpLeaderboards"
);
const blocklistAddMock = vi.spyOn(BlocklistDal, "add");
const friendsDeletebyUidMock = vi.spyOn(FriendsDal, "deleteByUid");
const friendsDeletebyUidMock = vi.spyOn(ConnectionsDal, "deleteByUid");
const logsDeleteUserMock = vi.spyOn(LogDal, "deleteUserLogs");
beforeEach(() => {
@ -989,7 +989,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 friendsUpdateNameMock = vi.spyOn(ConnectionsDal, "updateName");
const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog");
beforeEach(() => {
@ -4058,5 +4058,5 @@ async function enableFriendsEndpoints(enabled: boolean): Promise<void> {
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
await enableFriendsEndpoints(false);
const { body } = await call.expect(503);
expect(body.message).toEqual("Friends are not available at this time.");
expect(body.message).toEqual("Connections are not available at this time.");
}

View file

@ -100,9 +100,9 @@ export function getOpenApi(): OpenAPIObject {
"x-displayName": "Leaderboards",
},
{
name: "friends",
description: "User friend requests and friends list.",
"x-displayName": "Friends",
name: "connections",
description: "Connections between users.",
"x-displayName": "Connections",
"x-public": "no",
},
{

View file

@ -1,30 +1,30 @@
import {
CreateFriendRequestRequest,
CreateFriendRequestResponse,
GetFriendRequestsQuery,
GetFriendRequestsResponse,
CreateConnectionRequest,
CreateConnectionResponse,
GetConnectionsQuery,
GetConnectionsResponse,
IdPathParams,
UpdateFriendRequestsRequest,
} from "@monkeytype/contracts/friends";
UpdateConnectionRequest,
} from "@monkeytype/contracts/connections";
import { MonkeyRequest } from "../types";
import { MonkeyResponse } from "../../utils/monkey-response";
import * as FriendsDal from "../../dal/friends";
import * as ConnectionsDal from "../../dal/connections";
import * as UserDal from "../../dal/user";
import { replaceObjectId } from "../../utils/misc";
import MonkeyError from "../../utils/error";
import { omit } from "lodash";
import { FriendRequest } from "@monkeytype/schemas/friends";
import { Connection } from "@monkeytype/schemas/connections";
function convert(db: FriendsDal.DBFriendRequest): FriendRequest {
function convert(db: ConnectionsDal.DBConnection): Connection {
return replaceObjectId(omit(db, "key"));
}
export async function getRequests(
req: MonkeyRequest<GetFriendRequestsQuery>
): Promise<GetFriendRequestsResponse> {
req: MonkeyRequest<GetConnectionsQuery>
): Promise<GetConnectionsResponse> {
const { uid } = req.ctx.decodedToken;
const { status, type } = req.query;
const results = await FriendsDal.getRequests({
const results = await ConnectionsDal.getConnections({
initiatorUid:
type === undefined || type.includes("outgoing") ? uid : undefined,
friendUid:
@ -32,30 +32,30 @@ export async function getRequests(
status: status,
});
return new MonkeyResponse("Friend requests retrieved", results.map(convert));
return new MonkeyResponse("Connections retrieved", results.map(convert));
}
export async function createRequest(
req: MonkeyRequest<undefined, CreateFriendRequestRequest>
): Promise<CreateFriendRequestResponse> {
req: MonkeyRequest<undefined, CreateConnectionRequest>
): Promise<CreateConnectionResponse> {
const { uid } = req.ctx.decodedToken;
const { friendName } = req.body;
const { maxPerUser } = req.ctx.configuration.connections;
const friend = await UserDal.getUserByName(friendName, "create friend");
const friend = await UserDal.getUserByName(friendName, "create connection");
if (uid === friend.uid) {
throw new MonkeyError(400, "You cannot be your own friend, sorry.");
}
const initiator = await UserDal.getPartialUser(uid, "create friend", [
const initiator = await UserDal.getPartialUser(uid, "create connection", [
"uid",
"name",
]);
const result = await FriendsDal.create(initiator, friend, maxPerUser);
const result = await ConnectionsDal.create(initiator, friend, maxPerUser);
return new MonkeyResponse("Friend created", convert(result));
return new MonkeyResponse("Connection created", convert(result));
}
export async function deleteRequest(
@ -64,19 +64,19 @@ export async function deleteRequest(
const { uid } = req.ctx.decodedToken;
const { id } = req.params;
await FriendsDal.deleteById(uid, id);
await ConnectionsDal.deleteById(uid, id);
return new MonkeyResponse("Friend deleted", null);
return new MonkeyResponse("Connection deleted", null);
}
export async function updateRequest(
req: MonkeyRequest<undefined, UpdateFriendRequestsRequest, IdPathParams>
req: MonkeyRequest<undefined, UpdateConnectionRequest, IdPathParams>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { id } = req.params;
const { status } = req.body;
await FriendsDal.updateStatus(uid, id, status);
await ConnectionsDal.updateStatus(uid, id, status);
return new MonkeyResponse("Friend updated", null);
return new MonkeyResponse("Connection updated", null);
}

View file

@ -90,7 +90,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";
import * as ConnectionsDal from "../../dal/connections";
async function verifyCaptcha(captcha: string): Promise<void> {
const { data: verified, error } = await tryCatch(verify(captcha));
@ -294,7 +294,7 @@ export async function deleteUser(req: MonkeyRequest): Promise<MonkeyResponse> {
uid,
req.ctx.configuration.leaderboards.weeklyXp
),
FriendsDal.deleteByUid(uid),
ConnectionsDal.deleteByUid(uid),
]);
try {
@ -386,7 +386,7 @@ export async function updateName(
await UserDAL.updateName(uid, name, user.name);
await FriendsDal.updateName(uid, name);
await ConnectionsDal.updateName(uid, name);
void addImportantLog(
"user_name_updated",
`changed name from ${user.name} to ${name}`,

View file

@ -0,0 +1,24 @@
import { connectionsContract } from "@monkeytype/contracts/connections";
import { initServer } from "@ts-rest/express";
import { callController } from "../ts-rest-adapter";
import * as ConnectionsController from "../controllers/connections";
const s = initServer();
export default s.router(connectionsContract, {
get: {
handler: async (r) => callController(ConnectionsController.getRequests)(r),
},
create: {
handler: async (r) =>
callController(ConnectionsController.createRequest)(r),
},
delete: {
handler: async (r) =>
callController(ConnectionsController.deleteRequest)(r),
},
update: {
handler: async (r) =>
callController(ConnectionsController.updateRequest)(r),
},
});

View file

@ -1,21 +0,0 @@
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),
},
});

View file

@ -16,7 +16,7 @@ import configs from "./configs";
import configuration from "./configuration";
import { version } from "../../version";
import leaderboards from "./leaderboards";
import friends from "./friends";
import connections from "./connections";
import addSwaggerMiddlewares from "./swagger";
import { MonkeyResponse } from "../../utils/monkey-response";
import {
@ -62,7 +62,7 @@ const router = s.router(contract, {
users,
quotes,
webhooks,
friends,
connections,
});
export function addApiRoutes(app: Application): void {

View file

@ -1,33 +1,30 @@
import { Collection, Filter, ObjectId } from "mongodb";
import * as db from "../init/db";
import {
FriendRequest,
FriendRequestStatus,
} from "@monkeytype/schemas/friends";
import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections";
import MonkeyError from "../utils/error";
import { WithObjectId } from "../utils/misc";
export type DBFriendRequest = WithObjectId<
FriendRequest & {
export type DBConnection = WithObjectId<
Connection & {
key: string; //sorted uid
}
>;
// Export for use in tests
export const getCollection = (): Collection<DBFriendRequest> =>
db.collection("friends");
export const getCollection = (): Collection<DBConnection> =>
db.collection("connections");
export async function getRequests(options: {
export async function getConnections(options: {
initiatorUid?: string;
friendUid?: string;
status?: FriendRequestStatus[];
}): Promise<DBFriendRequest[]> {
status?: ConnectionStatus[];
}): Promise<DBConnection[]> {
const { initiatorUid, friendUid, status } = options;
if (initiatorUid === undefined && friendUid === undefined)
throw new Error("no filter provided");
let filter: Filter<DBFriendRequest> = { $or: [] };
let filter: Filter<DBConnection> = { $or: [] };
if (initiatorUid !== undefined) {
filter.$or?.push({ initiatorUid });
@ -48,7 +45,7 @@ export async function create(
initiator: { uid: string; name: string },
friend: { uid: string; name: string },
maxPerUser: number
): Promise<DBFriendRequest> {
): Promise<DBConnection> {
const count = await getCollection().countDocuments({
initiatorUid: initiator.uid,
});
@ -56,12 +53,12 @@ export async function create(
if (count >= maxPerUser) {
throw new MonkeyError(
409,
"Maximum number of friends reached",
"create friend request"
"Maximum number of connections reached",
"create connection request"
);
}
try {
const created: DBFriendRequest = {
const created: DBConnection = {
_id: new ObjectId(),
key: getKey(initiator.uid, friend.uid),
initiatorUid: initiator.uid,
@ -78,7 +75,7 @@ export async function create(
} 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 new MonkeyError(409, "Duplicate connection or blocked");
}
throw e;
@ -86,16 +83,16 @@ export async function create(
}
/**
*Update the status of a friend by id
*Update the status of a connection by id
* @param friendUid
* @param id
* @param status
* @throws MonkeyError if the friend id is unknown or the friendUid does not match
* @throws MonkeyError if the connection id is unknown or the friendUid does not match
*/
export async function updateStatus(
friendUid: string,
id: string,
status: FriendRequestStatus
status: ConnectionStatus
): Promise<void> {
const updateResult = await getCollection().updateOne(
{
@ -106,15 +103,15 @@ export async function updateStatus(
);
if (updateResult.matchedCount === 0) {
throw new MonkeyError(404, "Friend not found");
throw new MonkeyError(404, "Connection not found");
}
}
/**
* delete a friend by the id.
* delete a connection by the id.
* @param uid
* @param id
* @throws MonkeyError if the friend id is unknown or uid does not match
* @throws MonkeyError if the connection id is unknown or uid does not match
*/
export async function deleteById(uid: string, id: string): Promise<void> {
const deletionResult = await getCollection().deleteOne({
@ -137,7 +134,7 @@ export async function deleteById(uid: string, id: string): Promise<void> {
}
/**
* Update all friends for the uid (initiator or friend) with the given name.
* Update all connections for the uid (initiator or friend) with the given name.
* @param uid
* @param newName
*/
@ -159,7 +156,7 @@ export async function updateName(uid: string, newName: string): Promise<void> {
}
/**
* Remove all friends containing the uid as initiatorUid or friendUid
* Remove all connections containing the uid as initiatorUid or friendUid
* @param uid
*/
export async function deleteByUid(uid: string): Promise<void> {
@ -179,6 +176,6 @@ export async function createIndicies(): Promise<void> {
await getCollection().createIndex({ initiatorUid: 1 });
await getCollection().createIndex({ friendUid: 1 });
//make sure there is only one friend entry for each friend/creator pair
//make sure there is only one connection for each friend/creator pair
await getCollection().createIndex({ key: 1 }, { unique: true });
}

View file

@ -34,7 +34,7 @@ import { Result as ResultType } from "@monkeytype/schemas/results";
import { Configuration } from "@monkeytype/schemas/configuration";
import { isToday, isYesterday } from "@monkeytype/util/date-and-time";
import GeorgeQueue from "../queues/george-queue";
import { getCollection as getConnectionCollection } from "./friends";
import { getCollection as getConnectionCollection } from "./connections";
export type DBUserTag = WithObjectId<UserTag>;

View file

@ -17,7 +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 { createIndicies as connectionsDbSetup } from "./dal/connections";
import { getErrorMessage } from "./utils/error";
async function bootServer(port: number): Promise<Server> {
@ -77,8 +77,8 @@ async function bootServer(port: number): Promise<Server> {
Logger.info("Setting up blocklist indicies...");
await blocklistDbSetup();
Logger.info("Setting up friends indicies...");
await friendsDbSetup();
Logger.info("Setting up connections indicies...");
await connectionsDbSetup();
recordServerVersion(version);
} catch (error) {

View file

@ -0,0 +1,136 @@
import { initContract } from "@ts-rest/core";
import {
ConnectionSchema,
ConnectionStatusSchema,
ConnectionTypeSchema,
} from "@monkeytype/schemas/connections";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import { IdSchema } from "@monkeytype/schemas/util";
const c = initContract();
export const GetConnectionsResponseSchema = responseWithData(
z.array(ConnectionSchema)
);
export type GetConnectionsResponse = z.infer<
typeof GetConnectionsResponseSchema
>;
export const GetConnectionsQuerySchema = z.object({
status: z
.array(ConnectionStatusSchema)
.or(ConnectionStatusSchema.transform((it) => [it]))
.optional(),
type: z
.array(ConnectionTypeSchema)
.or(ConnectionTypeSchema.transform((it) => [it]))
.optional(),
});
export type GetConnectionsQuery = z.infer<typeof GetConnectionsQuerySchema>;
export const CreateConnectionRequestSchema = ConnectionSchema.pick({
friendName: true,
});
export type CreateConnectionRequest = z.infer<
typeof CreateConnectionRequestSchema
>;
export const CreateConnectionResponseSchema =
responseWithData(ConnectionSchema);
export type CreateConnectionResponse = z.infer<
typeof CreateConnectionResponseSchema
>;
export const IdPathParamsSchema = z.object({
id: IdSchema,
});
export type IdPathParams = z.infer<typeof IdPathParamsSchema>;
export const UpdateConnectionRequestSchema = z.object({
status: ConnectionStatusSchema.exclude(["pending"]),
});
export type UpdateConnectionRequest = z.infer<
typeof UpdateConnectionRequestSchema
>;
export const connectionsContract = c.router(
{
get: {
summary: "get connections",
description: "Get connections of the current user",
method: "GET",
path: "/",
query: GetConnectionsQuerySchema.strict(),
responses: {
200: GetConnectionsResponseSchema,
},
metadata: meta({
rateLimit: "connectionGet",
}),
},
create: {
summary: "create connection",
description: "Request a connection to a user ",
method: "POST",
path: "/",
body: CreateConnectionRequestSchema.strict(),
responses: {
200: CreateConnectionResponseSchema,
404: MonkeyResponseSchema.describe("FriendUid unknown"),
409: MonkeyResponseSchema.describe(
"Duplicate connection, blocked or max connections reached"
),
},
metadata: meta({
rateLimit: "connectionCreate",
}),
},
delete: {
summary: "delete connection",
description: "Delete a connection",
method: "DELETE",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionDelete",
}),
},
update: {
summary: "update connection",
description: "Update a connection status",
method: "PATCH",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: UpdateConnectionRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionUpdate",
}),
},
},
{
pathPrefix: "/connections",
strictStatusCodes: true,
metadata: meta({
openApiTags: "connections",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Connections are not available at this time.",
},
}),
commonResponses: CommonResponses,
}
);

View file

@ -1,138 +0,0 @@
import { initContract } from "@ts-rest/core";
import {
FriendRequestSchema,
FriendRequestStatusSchema,
FriendRequestTypeSchema,
} 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<typeof IdPathParamsSchema>;
export const UpdateFriendRequestsRequestSchema = z.object({
status: FriendRequestStatusSchema.exclude(["pending"]),
});
export type UpdateFriendRequestsRequest = z.infer<
typeof UpdateFriendRequestsRequestSchema
>;
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",
}),
},
},
{
pathPrefix: "/friends",
strictStatusCodes: true,
metadata: meta({
openApiTags: "friends",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Friends are not available at this time.",
},
}),
commonResponses: CommonResponses,
}
);

View file

@ -12,7 +12,7 @@ import { devContract } from "./dev";
import { usersContract } from "./users";
import { quotesContract } from "./quotes";
import { webhooksContract } from "./webhooks";
import { friendsContract } from "./friends";
import { connectionsContract } from "./connections";
const c = initContract();
@ -30,7 +30,7 @@ export const contract = c.router({
users: usersContract,
quotes: quotesContract,
webhooks: webhooksContract,
friends: friendsContract,
connections: connectionsContract,
});
/**

View file

@ -346,6 +346,11 @@ export const limits = {
max: 60,
},
userFriendGet: {
window: "hour",
max: 60,
},
// ApeKeys Routing
apeKeysGet: {
window: "hour",
@ -362,27 +367,22 @@ export const limits = {
max: 1,
},
friendRequestsGet: {
connectionGet: {
window: "hour",
max: 60,
},
friendRequestsCreate: {
connectionCreate: {
window: "hour",
max: 60,
},
friendRequestsDelete: {
connectionDelete: {
window: "hour",
max: 60,
},
friendRequestsUpdate: {
window: "hour",
max: 60,
},
friendGet: {
connectionUpdate: {
window: "hour",
max: 60,
},

View file

@ -939,10 +939,10 @@ export const usersContract = c.router(
200: GetFriendsResponseSchema,
},
metadata: meta({
rateLimit: "friendGet",
rateLimit: "userFriendGet",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Friends are not available at this time.",
invalidMessage: "Connections are not available at this time.",
},
}),
},

View file

@ -16,7 +16,7 @@ export type OpenApiTag =
| "users"
| "quotes"
| "webhooks"
| "friends";
| "connections";
export type PermissionId =
| "quoteMod"

View file

@ -0,0 +1,24 @@
import { z } from "zod";
import { IdSchema } from "./util";
export const ConnectionStatusSchema = z.enum([
"pending",
"accepted",
"blocked",
]);
export type ConnectionStatus = z.infer<typeof ConnectionStatusSchema>;
export const ConnectionTypeSchema = z.enum(["incoming", "outgoing"]);
export type ConnectionType = z.infer<typeof ConnectionTypeSchema>;
export const ConnectionSchema = z.object({
_id: IdSchema,
initiatorUid: IdSchema,
initiatorName: z.string(),
friendUid: IdSchema,
friendName: z.string(),
addedAt: z.number().int().nonnegative(),
status: ConnectionStatusSchema,
});
export type Connection = z.infer<typeof ConnectionSchema>;

View file

@ -1,24 +0,0 @@
import { z } from "zod";
import { IdSchema } from "./util";
export const FriendRequestStatusSchema = z.enum([
"pending",
"accepted",
"blocked",
]);
export type FriendRequestStatus = z.infer<typeof FriendRequestStatusSchema>;
export const FriendRequestTypeSchema = z.enum(["incoming", "outgoing"]);
export type FriendRequestType = z.infer<typeof FriendRequestTypeSchema>;
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<typeof FriendRequestSchema>;