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",
|
||||
"sourceMaps": true,
|
||||
"exclude": ["test/*"],
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"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: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",
|
||||
|
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
@ -31,6 +31,8 @@ export class KweeksService {
|
|||
|
||||
await this.kweekRepository.addAttachments(id, attachments);
|
||||
|
||||
console.log(id);
|
||||
|
||||
return await this.kweekRepository.findOne(id, false);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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<UserModel | undefined> {
|
||||
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<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)
|
||||
.executeTakeFirst();
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
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