feat: added load and smoke test

This commit is contained in:
Hackntosh 2024-11-01 20:57:42 +00:00
parent 2b8bea92fd
commit f1808e35b3
12 changed files with 239 additions and 27 deletions

1
.swcrc
View file

@ -1,6 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/swcrc", "$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true, "sourceMaps": true,
"exclude": ["test/*"],
"jsc": { "jsc": {
"target": "esnext", "target": "esnext",
"parser": { "parser": {

BIN
bun.lockb

Binary file not shown.

View file

@ -11,7 +11,7 @@
"docker": "docker compose --env-file docker.env up -d", "docker": "docker compose --env-file docker.env up -d",
"docker:build": "docker build -t api . && docker compose up -d", "docker:build": "docker build -t api . && docker compose up -d",
"docker:db": "docker compose -f docker-compose.db.yml 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:deploy": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev", "migrate:dev": "prisma migrate dev",
"migrate:dev:create": "prisma migrate dev --create-only", "migrate:dev:create": "prisma migrate dev --create-only",
@ -25,7 +25,8 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json", "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: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": { "dependencies": {
"@aws-sdk/client-s3": "^3.670.0", "@aws-sdk/client-s3": "^3.670.0",
@ -44,6 +45,7 @@
"@nestjs/throttler": "6.2.1", "@nestjs/throttler": "6.2.1",
"@prisma/client": "^5.20.0", "@prisma/client": "^5.20.0",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"cache-manager": "^6.1.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6", "dotenv-expand": "^11.0.6",
"file-type": "16.5.4", "file-type": "16.5.4",
@ -66,6 +68,7 @@
"@biomejs/biome": "1.5.3", "@biomejs/biome": "1.5.3",
"@commitlint/cli": "^18.6.1", "@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.3", "@commitlint/config-conventional": "^18.6.3",
"@faker-js/faker": "^9.1.0",
"@nestjs/cli": "^10.4.5", "@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4", "@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.4", "@nestjs/testing": "^10.4.4",
@ -73,6 +76,7 @@
"@swc/core": "1.7.25", "@swc/core": "1.7.25",
"@swc/jest": "^0.2.36", "@swc/jest": "^0.2.36",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/k6": "^0.54.1",
"@types/node": "^20.16.11", "@types/node": "^20.16.11",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",

View file

@ -1,7 +1,7 @@
import { FastifyMulterModule } from "@nest-lab/fastify-multer"; import { FastifyMulterModule } from "@nest-lab/fastify-multer";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Module } from "@nestjs/common"; 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 { APP_GUARD, APP_PIPE } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule, seconds } from "@nestjs/throttler"; import { ThrottlerGuard, ThrottlerModule, seconds } from "@nestjs/throttler";
import { S3Module } from "nestjs-s3"; import { S3Module } from "nestjs-s3";
@ -16,7 +16,15 @@ import { UserModule } from "./users/users.module";
@Module({ @Module({
imports: [ imports: [
ThrottlerModule.forRoot({ 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()), storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()),
errorMessage: "Too many requests", errorMessage: "Too many requests",
}), }),

View file

@ -31,6 +31,8 @@ export class KweeksService {
await this.kweekRepository.addAttachments(id, attachments); await this.kweekRepository.addAttachments(id, attachments);
console.log(id);
return await this.kweekRepository.findOne(id, false); return await this.kweekRepository.findOne(id, false);
} }

View file

@ -1,4 +1,5 @@
import helmet from "@fastify/helmet"; import helmet from "@fastify/helmet";
import { VersioningType } from "@nestjs/common";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { import {
FastifyAdapter, FastifyAdapter,
@ -12,12 +13,9 @@ import { Configuration } from "./configuration";
/* /*
--- Present --- --- Present ---
TODO: Add a authorization system.
TODO: Send e-mails to the user when something happens to his account. TODO: Send e-mails to the user when something happens to his account.
TODO: Create the chat system. TODO: Create the chat system.
-> Initialize the websocket system first. -> Initialize the websocket system first.
TODO: Create a TOS.
TODO: Fix Docker Image.
*/ */
async function bootstrap() { async function bootstrap() {
@ -26,6 +24,11 @@ async function bootstrap() {
new FastifyAdapter({ logger: true }), new FastifyAdapter({ logger: true }),
); );
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: "1",
});
patchNestJsSwagger(); patchNestJsSwagger();
app.enableCors(); app.enableCors();

View file

@ -16,12 +16,6 @@ export class S3Service {
constructor(@InjectS3() private readonly s3: S3) {} 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. * Returns the image url if the upload was successful.
*/ */

View file

@ -10,11 +10,9 @@ export const UserSchema = z
password: z.password(), password: z.password(),
kweeks: z.array(z.object({})).optional(), kweeks: z.array(z.object({})).optional(),
profileImage: z.string().url().optional(), profileImage: z.string().url().optional(),
likedKweeks: z.array(z.object({})).optional(),
likedComments: z.array(z.object({})).optional(),
followers: z.number(), followers: z.number(),
following: z.number(), following: z.number(),
kweeksComments: z.array(z.object({})).optional(), comments: z.array(z.object({})).optional(),
createdAt: z.date(), createdAt: z.date(),
}) })
.required(); .required();

View file

@ -1,4 +1,5 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/postgres";
import { Database } from "src/services/kysely/kysely.service"; import { Database } from "src/services/kysely/kysely.service";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { UserModel } from "../models/user.model"; import { UserModel } from "../models/user.model";
@ -33,7 +34,48 @@ export class UsersRepository {
async findByUsername(username: string): Promise<UserModel | undefined> { async findByUsername(username: string): Promise<UserModel | undefined> {
const user = await this.database const user = await this.database
.selectFrom("User") .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<number>().as("followers")),
following: eq
.selectFrom("Follows")
.whereRef("followingId", "=", "User.id")
.select(eq.fn.countAll<number>().as("following")),
kweeks: eq
.selectFrom("Kweek")
.whereRef("authorId", "=", "User.id")
.select(eq.fn.countAll<number>().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<number>().as("likes")),
comments: qb
.selectFrom("Comments")
.whereRef("kweekId", "=", "Kweek.id")
.select(eq.fn.countAll<number>().as("comments")),
}).as("count"),
])
.whereRef("authorId", "=", "User.id"),
).as("kweeks"),
])
.where("username", "=", username) .where("username", "=", username)
.executeTakeFirst(); .executeTakeFirst();

View file

@ -28,16 +28,7 @@ export class UserService {
throw new NotFoundException("User not found"); throw new NotFoundException("User not found");
} }
const followers = await this.userRepository.countFollowers(user.id); return user;
const following = await this.userRepository.countFollowing(user.id);
const kweeks = await this.userRepository.getUserKweeks(user.id);
return {
...user,
followers,
following,
kweeks,
};
} }
async create({ async create({

View file

@ -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",
};
}

146
test/load-test.spec.ts Normal file
View file

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