mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-24 23:07:25 +08:00
feat: add friends (@fehmer)
This commit is contained in:
parent
61766d3a8c
commit
6056111eae
12 changed files with 309 additions and 2 deletions
101
backend/__tests__/api/controllers/friends.spec.ts
Normal file
101
backend/__tests__/api/controllers/friends.spec.ts
Normal 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.");
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
||||
|
|
|
|||
40
backend/src/api/controllers/friends.ts
Normal file
40
backend/src/api/controllers/friends.ts
Normal 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);
|
||||
}
|
||||
18
backend/src/api/routes/friends.ts
Normal file
18
backend/src/api/routes/friends.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
92
packages/contracts/src/friends.ts
Normal file
92
packages/contracts/src/friends.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export type OpenApiTag =
|
|||
| "development"
|
||||
| "users"
|
||||
| "quotes"
|
||||
| "webhooks";
|
||||
| "webhooks"
|
||||
| "friends";
|
||||
|
||||
export type PermissionId =
|
||||
| "quoteMod"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
17
packages/contracts/src/schemas/friends.ts
Normal file
17
packages/contracts/src/schemas/friends.ts
Normal 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>;
|
||||
Loading…
Add table
Reference in a new issue