diff --git a/.swcrc b/.swcrc index 3e08dc1..24f65fd 100644 --- a/.swcrc +++ b/.swcrc @@ -1,6 +1,7 @@ { "$schema": "https://json.schemastore.org/swcrc", "sourceMaps": true, + "exclude": ["test/*"], "jsc": { "target": "esnext", "parser": { diff --git a/bun.lockb b/bun.lockb index 598674e..f203488 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 29a56aa..1a8dfd0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "docker": "docker compose --env-file docker.env up -d", "docker:build": "docker build -t api . && docker compose up -d", "docker:db": "docker compose -f docker-compose.db.yml up -d", - "lint": "npx @biomejs/biome check --write .", + "lint": "npx @biomejs/biome check . --apply", "migrate:deploy": "prisma migrate deploy", "migrate:dev": "prisma migrate dev", "migrate:dev:create": "prisma migrate dev --create-only", @@ -25,7 +25,8 @@ "test:cov": "jest --coverage", "test:e2e": "jest --config ./test/jest-e2e.json", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "test:load": "k6 run ./test/load-test.spec.ts --compatibility-mode=experimental_enhanced" }, "dependencies": { "@aws-sdk/client-s3": "^3.670.0", @@ -44,6 +45,7 @@ "@nestjs/throttler": "6.2.1", "@prisma/client": "^5.20.0", "argon2": "^0.41.1", + "cache-manager": "^6.1.2", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "file-type": "16.5.4", @@ -66,6 +68,7 @@ "@biomejs/biome": "1.5.3", "@commitlint/cli": "^18.6.1", "@commitlint/config-conventional": "^18.6.3", + "@faker-js/faker": "^9.1.0", "@nestjs/cli": "^10.4.5", "@nestjs/schematics": "^10.1.4", "@nestjs/testing": "^10.4.4", @@ -73,6 +76,7 @@ "@swc/core": "1.7.25", "@swc/jest": "^0.2.36", "@types/jest": "^29.5.13", + "@types/k6": "^0.54.1", "@types/node": "^20.16.11", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", diff --git a/src/app.module.ts b/src/app.module.ts index 9b81055..8945cb2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,7 @@ import { FastifyMulterModule } from "@nest-lab/fastify-multer"; import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; import { Module } from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ConfigModule } from "@nestjs/config"; import { APP_GUARD, APP_PIPE } from "@nestjs/core"; import { ThrottlerGuard, ThrottlerModule, seconds } from "@nestjs/throttler"; import { S3Module } from "nestjs-s3"; @@ -16,7 +16,15 @@ import { UserModule } from "./users/users.module"; @Module({ imports: [ ThrottlerModule.forRoot({ - throttlers: [{ limit: 10, ttl: seconds(60) }], + throttlers: [ + { + limit: 10, + ttl: seconds(60), + skipIf: () => { + return Configuration.NODE_ENV() === "dev" ? true : false; + }, + }, + ], storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()), errorMessage: "Too many requests", }), diff --git a/src/kweeks/kweeks.service.ts b/src/kweeks/kweeks.service.ts index 947e256..e9bdb1e 100644 --- a/src/kweeks/kweeks.service.ts +++ b/src/kweeks/kweeks.service.ts @@ -31,6 +31,8 @@ export class KweeksService { await this.kweekRepository.addAttachments(id, attachments); + console.log(id); + return await this.kweekRepository.findOne(id, false); } diff --git a/src/main.ts b/src/main.ts index 5258f5a..5ff5ab0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import helmet from "@fastify/helmet"; +import { VersioningType } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { FastifyAdapter, @@ -12,12 +13,9 @@ import { Configuration } from "./configuration"; /* --- Present --- - TODO: Add a authorization system. TODO: Send e-mails to the user when something happens to his account. TODO: Create the chat system. -> Initialize the websocket system first. - TODO: Create a TOS. - TODO: Fix Docker Image. */ async function bootstrap() { @@ -26,6 +24,11 @@ async function bootstrap() { new FastifyAdapter({ logger: true }), ); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: "1", + }); + patchNestJsSwagger(); app.enableCors(); diff --git a/src/services/s3/s3.service.ts b/src/services/s3/s3.service.ts index d70f561..4026981 100644 --- a/src/services/s3/s3.service.ts +++ b/src/services/s3/s3.service.ts @@ -16,12 +16,6 @@ export class S3Service { constructor(@InjectS3() private readonly s3: S3) {} - /* - TODO: Remove single image upload since we can use the multiple one. - TODO: Find a way to automatically get the image complete URL. - -> iirc, S3 api has a function for that. - */ - /** * Returns the image url if the upload was successful. */ diff --git a/src/users/models/user.model.ts b/src/users/models/user.model.ts index 13c3e23..497a3b7 100644 --- a/src/users/models/user.model.ts +++ b/src/users/models/user.model.ts @@ -10,11 +10,9 @@ export const UserSchema = z password: z.password(), kweeks: z.array(z.object({})).optional(), profileImage: z.string().url().optional(), - likedKweeks: z.array(z.object({})).optional(), - likedComments: z.array(z.object({})).optional(), followers: z.number(), following: z.number(), - kweeksComments: z.array(z.object({})).optional(), + comments: z.array(z.object({})).optional(), createdAt: z.date(), }) .required(); diff --git a/src/users/repository/users.repository.ts b/src/users/repository/users.repository.ts index fa4b75e..535c8f4 100644 --- a/src/users/repository/users.repository.ts +++ b/src/users/repository/users.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from "@nestjs/common"; +import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/postgres"; import { Database } from "src/services/kysely/kysely.service"; import { v4 as uuid } from "uuid"; import { UserModel } from "../models/user.model"; @@ -33,7 +34,48 @@ export class UsersRepository { async findByUsername(username: string): Promise { const user = await this.database .selectFrom("User") - .select(["id", "displayName", "username", "createdAt"]) + .select((eq) => [ + "id", + "displayName", + "username", + "createdAt", + jsonBuildObject({ + followers: eq + .selectFrom("Follows") + .whereRef("followerId", "=", "User.id") + .select(eq.fn.countAll().as("followers")), + following: eq + .selectFrom("Follows") + .whereRef("followingId", "=", "User.id") + .select(eq.fn.countAll().as("following")), + kweeks: eq + .selectFrom("Kweek") + .whereRef("authorId", "=", "User.id") + .select(eq.fn.countAll().as("kweeks")), + }).as("count"), + jsonArrayFrom( + eq + .selectFrom("Kweek") + .select((qb) => [ + "id", + "content", + "attachments", + "createdAt", + "updatedAt", + jsonBuildObject({ + likes: qb + .selectFrom("KweekLike") + .whereRef("kweekId", "=", "Kweek.id") + .select(eq.fn.countAll().as("likes")), + comments: qb + .selectFrom("Comments") + .whereRef("kweekId", "=", "Kweek.id") + .select(eq.fn.countAll().as("comments")), + }).as("count"), + ]) + .whereRef("authorId", "=", "User.id"), + ).as("kweeks"), + ]) .where("username", "=", username) .executeTakeFirst(); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 08efde3..e0d31d6 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -28,16 +28,7 @@ export class UserService { throw new NotFoundException("User not found"); } - const followers = await this.userRepository.countFollowers(user.id); - const following = await this.userRepository.countFollowing(user.id); - const kweeks = await this.userRepository.getUserKweeks(user.id); - - return { - ...user, - followers, - following, - kweeks, - }; + return user; } async create({ diff --git a/test/generators/user.generator.ts b/test/generators/user.generator.ts new file mode 100644 index 0000000..3a5cedc --- /dev/null +++ b/test/generators/user.generator.ts @@ -0,0 +1,23 @@ +// unfortunately k6 runtime only recognizes modules like this +import { faker } from "../../node_modules/@faker-js/faker/dist/index.cjs"; + +const usedUsernames = new Set(); + +export function generateUser() { + let username: string; + + do { + const uniqueId = `${faker.number.int({ min: 5, max: 10000 })}_${Math.floor( + Math.random() * 10000, + )}`; + username = `user_${uniqueId}`; + } while (usedUsernames.has(username)); + + usedUsernames.add(username); + + return { + username, + email: faker.internet.email().toLowerCase(), + password: "S@omeSt!0ongPa22sword", + }; +} diff --git a/test/load-test.spec.ts b/test/load-test.spec.ts new file mode 100644 index 0000000..6a0b79f --- /dev/null +++ b/test/load-test.spec.ts @@ -0,0 +1,146 @@ +// Ignore this error since k6 uses another runtime that's able to fetch this import. +import { FormData } from "https://jslib.k6.io/formdata/0.0.2/index.js"; +import { check, sleep } from "k6"; +import http from "k6/http"; +import { Options } from "k6/options"; +// @ts-ignore +import { generateUser } from "./generators/user.generator.ts"; + +// biome-ignore lint/style/useConst: it's like that on the wiki +export let options: Options = { + scenarios: { + smoke_test: { + executor: "constant-vus", + exec: "smoke_test", + vus: 20, + duration: "5s", + env: { NODE_ENV: "dev" }, + }, + load_test: { + executor: "ramping-vus", + exec: "load_test", + startTime: "5s", + stages: [ + { target: 15, duration: "30s" }, + { target: 15, duration: "1m" }, + { target: 30, duration: "1m30s" }, + { target: 55, duration: "2m" }, + { target: 0, duration: "10s" }, + ], + env: { NODE_ENV: "dev" }, + }, + }, + thresholds: { + "http_req_duration{scenario:smoke_test}": ["p(95)<50"], // smoke_test: 95% of the requests must be under 50ms + "http_req_failed{scenario:smoke_test}": ["rate<0.01"], // smoke_test: Less than 1% acceptable errors + "http_req_duration{scenario:load_test}": ["p(95)<90"], // load_test: 95% of the requests must be under 90ms + "http_req_failed{scenario:load_test}": ["rate<0.01"], // load_test: less than 1% acceptable errors + }, +}; + +const BASE_URL = "http://localhost:8080/v1"; + +export function smoke_test() { + const index_req = http.get("http://localhost:8080/"); + check(index_req, { + "Response successful": (r) => r.status === 200, + }); + + sleep(1); +} + +export function load_test() { + executeUserFlow(); +} + +function executeUserFlow() { + const user = generateUser(); + + // Create user + const createUser = http.post(`${BASE_URL}/users`, JSON.stringify(user), { + headers: { "Content-Type": "application/json" }, + }); + + check(createUser, { + "User created successfully": (r) => r.status === 201, + }); + + if (createUser.status !== 201) return; + + sleep(1); + + // Auth user + const authPayload = { username: user.username, password: user.password }; + const authUser = http.post(`${BASE_URL}/auth`, JSON.stringify(authPayload), { + headers: { "Content-Type": "application/json" }, + }); + + check(authUser, { + "User authenticated successfully": (r) => r.status === 200, + }); + + const authToken = authUser.json("token"); + if (!authToken) return; + + const headers = { + Authorization: `Bearer ${authToken}`, + }; + + sleep(1); + + // Create post + const fd = new FormData(); + fd.append("content", "Example post k6"); + fd.append("attachments", ""); + const createPost = http.post(`${BASE_URL}/kweeks`, fd.body(), { + headers: { + "Content-Type": `multipart/form-data; boundary=${fd.boundary}`, + ...headers, + }, + }); + + check(createPost, { + "Post created successfully": (r) => r.status === 201, + }); + + const postId = createPost.json("id"); + if (!postId) return; + + sleep(1); + + // Like post + const likePost = http.post(`${BASE_URL}/kweeks/${postId}/like`, null, { + headers, + }); + check(likePost, { + "Post liked successfully": (r) => r.status === 201, + }); + + sleep(1); + + // Get user info + const userInfo = http.get(`${BASE_URL}/users/${user.username}`, { headers }); + check(userInfo, { + "User profile loaded successfully": (r) => r.status === 200, + }); + + sleep(1); + + // Delete post + const deletePost = http.del(`${BASE_URL}/kweeks/${postId}`, null, { + headers, + }); + check(deletePost, { + "Post deleted successfully": (r) => r.status === 200, + }); + + sleep(1); + + // Delete user + const deleteUser = http.del(`${BASE_URL}/users`, null, { headers }); + check(deleteUser, { + "User deleted successfully": (r) => r.status === 200, + }); + + sleep(1); +}