feat: add friends (@fehmer)

This commit is contained in:
Christian Fehmer 2025-06-24 12:21:46 +02:00
parent 61766d3a8c
commit 6056111eae
No known key found for this signature in database
GPG key ID: FE53784A69964062
12 changed files with 309 additions and 2 deletions

View file

@ -0,0 +1,101 @@
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";
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 friends", () => {
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);
});
});
describe("create friend", () => {
it("should fail without mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/friends")
.send({})
.set("Authorization", `Bearer ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"friendUid" Required`],
});
});
it("should fail with extra properties", async () => {
//WHEN
const { body } = await mockApp
.post("/friends")
.send({ friendUid: "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")
.send({ friendUid: "1" })
.set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.post("/friends").expect(401);
});
});
describe("delete friend", () => {
it("should fail if friends endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp.delete("/friends/1").set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.delete("/friends/1").expect(401);
});
});
});
async function enableFriendsEndpoints(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
users: { friends: { enabled } },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
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.");
}

View file

@ -140,6 +140,12 @@ export function getOpenApi(): OpenAPIObject {
"x-displayName": "Webhooks",
"x-public": "yes",
},
{
name: "friends",
description: "User friends",
"x-displayName": "Friends",
"x-public": "no",
},
],
},
@ -277,7 +283,6 @@ function addRequiredConfiguration(
if (metadata === undefined || metadata.requireConfiguration === undefined)
return;
//@ts-expect-error
operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`;
}

View file

@ -0,0 +1,40 @@
import {
CreateFriendRequest,
CreateFriendResponse,
FriendIdPathParams,
GetFriendsQuery,
GetFriendsResponse,
} from "@monkeytype/contracts/friends";
import { MonkeyRequest } from "../types";
import { MonkeyResponse } from "../../utils/monkey-response";
import { Friend } from "@monkeytype/contracts/schemas/friends";
export async function get(
req: MonkeyRequest<GetFriendsQuery>
): Promise<GetFriendsResponse> {
const _status = req.query.status;
return new MonkeyResponse("Friends retrieved", []);
}
export async function create(
req: MonkeyRequest<undefined, CreateFriendRequest>
): Promise<CreateFriendResponse> {
const _friendUid = req.body.friendUid;
const data: Friend = {
_id: "id",
friendUid: "uid1",
friendName: "Bob",
addedAt: Date.now(),
initiatorName: "me",
initiatorUid: "myUid",
status: "pending",
};
return new MonkeyResponse("Friend created", data);
}
export async function deleteFriend(
req: MonkeyRequest<undefined, undefined, FriendIdPathParams>
): Promise<MonkeyResponse> {
const _id = req.params.id;
return new MonkeyResponse("Friend deleted", null);
}

View file

@ -0,0 +1,18 @@
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, {
get: {
handler: async (r) => callController(FriendsController.get)(r),
},
create: {
handler: async (r) => callController(FriendsController.create)(r),
},
delete: {
handler: async (r) => callController(FriendsController.deleteFriend)(r),
},
});

View file

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

View file

@ -78,6 +78,7 @@ export const BASE_CONFIGURATION: Configuration = {
premium: {
enabled: false,
},
friends: { enabled: false },
},
rateLimiting: {
badAuthentication: {
@ -302,6 +303,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema<Configuration> = {
},
},
},
friends: {
type: "object",
label: "Friends",
fields: {
enabled: {
type: "boolean",
label: "Enabled",
},
},
},
signUp: {
type: "boolean",
label: "Sign Up Enabled",

View file

@ -0,0 +1,92 @@
import { initContract } from "@ts-rest/core";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./schemas/api";
import { FriendSchema, FriendStatusSchema } from "./schemas/friends";
import { z } from "zod";
import { IdSchema } from "./schemas/util";
const c = initContract();
export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema));
export type GetFriendsResponse = z.infer<typeof GetFriendsResponseSchema>;
export const GetFriendsQuerySchema = z.object({
status: z.array(FriendStatusSchema).optional(),
});
export type GetFriendsQuery = z.infer<typeof GetFriendsQuerySchema>;
export const CreateFriendRequestSchema = FriendSchema.pick({
friendUid: true,
}).strict();
export type CreateFriendRequest = z.infer<typeof CreateFriendRequestSchema>;
export const CreateFriendResponseSchema = responseWithData(FriendSchema);
export type CreateFriendResponse = z.infer<typeof CreateFriendResponseSchema>;
export const FriendIdPathParamsSchema = z.object({
id: IdSchema,
});
export type FriendIdPathParams = z.infer<typeof FriendIdPathParamsSchema>;
export const friendsContract = c.router(
{
get: {
summary: "Get friends",
description: "Get friends of the current user",
method: "GET",
path: "",
query: GetFriendsQuerySchema,
responses: {
200: GetFriendsResponseSchema,
},
metadata: meta({
rateLimit: "friendsGet",
}),
},
create: {
summary: "Create friend",
description: "Request a user to become a friend",
method: "POST",
path: "",
body: CreateFriendRequestSchema,
responses: {
200: CreateFriendResponseSchema,
404: MonkeyResponseSchema.describe("FriendUid unknown"),
409: MonkeyResponseSchema.describe("Duplicate friend"),
},
metadata: meta({
rateLimit: "friendsCreate",
}),
},
delete: {
summary: "Delete friend",
description: "Remove a friend",
method: "DELETE",
path: "/:id",
pathParams: FriendIdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "friendsDelete",
}),
},
},
{
pathPrefix: "/friends",
strictStatusCodes: true,
metadata: meta({
openApiTags: "friends",
requireConfiguration: {
path: "users.friends.enabled",
invalidMessage: "Friends are not available at this time.",
},
}),
commonResponses: CommonResponses,
}
);

View file

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

View file

@ -361,6 +361,21 @@ export const limits = {
window: "second",
max: 1,
},
friendsGet: {
window: "hour",
max: 60,
},
friendsCreate: {
window: "hour",
max: 60,
},
friendsDelete: {
window: "hour",
max: 60,
},
} satisfies Record<string, RateLimitOptions>;
export type RateLimiterId = keyof typeof limits;

View file

@ -15,7 +15,8 @@ export type OpenApiTag =
| "development"
| "users"
| "quotes"
| "webhooks";
| "webhooks"
| "friends";
export type PermissionId =
| "quoteMod"

View file

@ -83,6 +83,9 @@ export const ConfigurationSchema = z.object({
premium: z.object({
enabled: z.boolean(),
}),
friends: z.object({
enabled: z.boolean(),
}),
}),
admin: z.object({
endpointsEnabled: z.boolean(),

View file

@ -0,0 +1,17 @@
import { z } from "zod";
import { IdSchema } from "./util";
export const FriendStatusSchema = z.enum(["pending", "accepted", "rejected"]);
export type FriendStatus = z.infer<typeof FriendStatusSchema>;
export const FriendSchema = z.object({
_id: IdSchema,
initiatorUid: IdSchema,
initiatorName: z.string(),
friendUid: IdSchema,
friendName: z.string(),
addedAt: z.number().int().nonnegative(),
status: FriendStatusSchema,
});
export type Friend = z.infer<typeof FriendSchema>;