mirror of
https://github.com/hknsh/project-knedita.git
synced 2024-11-28 17:41:15 +00:00
feat: added load and smoke test
This commit is contained in:
parent
2b8bea92fd
commit
f1808e35b3
12 changed files with 239 additions and 27 deletions
1
.swcrc
1
.swcrc
|
@ -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
BIN
bun.lockb
Binary file not shown.
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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({
|
||||||
|
|
23
test/generators/user.generator.ts
Normal file
23
test/generators/user.generator.ts
Normal 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
146
test/load-test.spec.ts
Normal 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);
|
||||||
|
}
|
Loading…
Reference in a new issue