feat: migrate from express to fastify

This commit is contained in:
Divlo 2021-10-24 04:18:18 +02:00
parent 714cc643ba
commit b77e602358
No known key found for this signature in database
GPG Key ID: 6F24DA54DA3967CF
281 changed files with 19768 additions and 22895 deletions

4
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,4 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.163.1/containers/javascript-node/.devcontainer/base.Dockerfile
ARG VARIANT="16-bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}

View File

@ -0,0 +1,21 @@
{
"name": "@thream/api",
"dockerComposeFile": "./docker-compose.yml",
"service": "workspace",
"workspaceFolder": "/workspace",
"settings": {
"remote.autoForwardPorts": false
},
"extensions": [
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"davidanson.vscode-markdownlint",
"prisma.prisma",
"mikestead.dotenv",
"ms-azuretools.vscode-docker"
],
"forwardPorts": [8080, 5555, 5432, 1080],
"postAttachCommand": ["npm", "install"],
"remoteUser": "node"
}

View File

@ -0,0 +1,28 @@
version: '3.0'
services:
workspace:
build:
context: './'
dockerfile: './Dockerfile'
volumes:
- '..:/workspace:cached'
command: 'sleep infinity'
thream-database:
image: 'postgres:14.0'
environment:
POSTGRES_USER: 'user'
POSTGRES_PASSWORD: 'password'
POSTGRES_DB: 'thream'
volumes:
- 'postgres-data:/var/lib/postgresql/data'
restart: 'unless-stopped'
thream-maildev:
image: 'maildev/maildev:1.1.0'
ports:
- '1080:80'
volumes:
postgres-data:

View File

@ -1,8 +1,8 @@
.vscode .vscode
.git .git
.env
build build
coverage coverage
node_modules node_modules
tmp tmp
temp temp
**/__test__/**

View File

@ -1,22 +1,17 @@
COMPOSE_PROJECT_NAME=thream-api COMPOSE_PROJECT_NAME='thream-api'
PORT=8080 HOST='0.0.0.0'
API_BASE_URL=http://localhost:8080 PORT='8080'
DATABASE_DIALECT=mysql DATABASE_URL='postgresql://user:password@thream-database:5432/thream'
DATABASE_HOST=thream-database JWT_ACCESS_EXPIRES_IN='15 minutes'
DATABASE_NAME=thream JWT_ACCESS_SECRET='accessTokenSecret'
DATABASE_USER=root JWT_REFRESH_SECRET='refreshTokenSecret'
DATABASE_PASSWORD=password DISCORD_CLIENT_ID=''
DATABASE_PORT=3306 DISCORD_CLIENT_SECRET=''
JWT_ACCESS_EXPIRES_IN=15 minutes GITHUB_CLIENT_ID=''
JWT_ACCESS_SECRET=accessTokenSecret GITHUB_CLIENT_SECRET=''
JWT_REFRESH_SECRET=refreshTokenSecret GOOGLE_CLIENT_ID=''
DISCORD_CLIENT_ID= GOOGLE_CLIENT_SECRET=''
DISCORD_CLIENT_SECRET= EMAIL_HOST='thream-maildev'
GITHUB_CLIENT_ID= EMAIL_USER='no-reply@thream.fr'
GITHUB_CLIENT_SECRET= EMAIL_PASSWORD='password'
GOOGLE_CLIENT_ID= EMAIL_PORT='25'
GOOGLE_CLIENT_SECRET=
EMAIL_HOST=thream-maildev
EMAIL_USER=no-reply@thream.fr
EMAIL_PASSWORD=password
EMAIL_PORT=25

5
.eslintignore Normal file
View File

@ -0,0 +1,5 @@
build
node_modules
coverage
package.json
package-lock.json

16
.eslintrc.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": ["standard-with-typescript", "eslint-config-prettier"],
"plugins": ["unicorn", "eslint-plugin-prettier"],
"parserOptions": {
"project": "./tsconfig.json"
},
"env": {
"node": true,
"jest": true
},
"rules": {
"prettier/prettier": "error",
"unicorn/prefer-node-protocol": "error",
"unicorn/prevent-abbreviations": "error"
}
}

View File

@ -13,12 +13,13 @@ jobs:
- uses: 'actions/checkout@v2' - uses: 'actions/checkout@v2'
- name: 'Use Node.js' - name: 'Use Node.js'
uses: 'actions/setup-node@v2.1.5' uses: 'actions/setup-node@v2.4.1'
with: with:
node-version: '16.x' node-version: '16.x'
cache: 'npm'
- name: 'Install' - name: 'Install'
run: 'npm ci --cache .npm --prefer-offline' run: 'npm install'
- name: 'Build' - name: 'Build'
run: 'npm run build' run: 'npm run build'

View File

@ -13,15 +13,21 @@ jobs:
- uses: 'actions/checkout@v2' - uses: 'actions/checkout@v2'
- name: 'Use Node.js' - name: 'Use Node.js'
uses: 'actions/setup-node@v2.1.5' uses: 'actions/setup-node@v2.4.1'
with: with:
node-version: '16.x' node-version: '16.x'
cache: 'npm'
- name: 'Install' - name: 'Install'
run: 'npm ci --cache .npm --prefer-offline' run: 'npm install'
- run: 'npm run lint:commit -- --to "${{ github.sha }}"' - run: 'npm run lint:commit -- --to "${{ github.sha }}"'
- run: 'npm run lint:editorconfig' - run: 'npm run lint:editorconfig'
- run: 'npm run lint:markdown' - run: 'npm run lint:markdown'
- run: 'npm run lint:docker' - run: 'npm run lint:docker'
- run: 'npm run lint:typescript' - run: 'npm run lint:typescript'
- name: 'dotenv-linter'
uses: 'dotenv-linter/action-dotenv-linter@v2'
with:
github_token: ${{ secrets.github_token }}

45
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: 'Release'
on:
push:
branches: [master]
jobs:
release:
runs-on: 'ubuntu-latest'
steps:
- uses: 'actions/checkout@v2.3.4'
with:
fetch-depth: 0
persist-credentials: false
- name: 'Import GPG key'
uses: 'crazy-max/ghaction-import-gpg@v3.2.0'
with:
gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
git-user-signingkey: true
git-commit-gpgsign: true
- name: 'Use Node.js'
uses: 'actions/setup-node@v2.4.1'
with:
node-version: '16.x'
cache: 'npm'
- name: 'Install'
run: 'npm install'
- name: 'Build'
run: 'npm run build'
- name: 'Production migration'
run: 'npm run prisma:migrate:deploy'
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: 'Release'
run: 'npm run release'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GIT_COMMITTER_NAME: ${{ secrets.GIT_NAME }}
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_EMAIL }}

View File

@ -13,12 +13,13 @@ jobs:
- uses: 'actions/checkout@v2' - uses: 'actions/checkout@v2'
- name: 'Use Node.js' - name: 'Use Node.js'
uses: 'actions/setup-node@v2.1.5' uses: 'actions/setup-node@v2.4.1'
with: with:
node-version: '16.x' node-version: '16.x'
cache: 'npm'
- name: 'Install' - name: 'Install'
run: 'npm ci --cache .npm --prefer-offline' run: 'npm install'
- name: 'Test' - name: 'Test'
run: 'npm run test' run: 'npm run test'

22
.gitignore vendored
View File

@ -15,12 +15,22 @@ coverage
# debug # debug
npm-debug.log* npm-debug.log*
# editors # IDEs and editors
.vscode /.idea
.theia .project
.idea .classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc # misc
.DS_Store .DS_Store
tmp uploads
temp

1
.husky/.gitignore vendored
View File

@ -1 +0,0 @@
_

View File

@ -1,8 +1,5 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
npm run lint:docker npm run lint:staged
npm run lint:editorconfig
npm run lint:markdown
npm run lint:typescript
npm run build npm run build

11
.lintstagedrc.json Normal file
View File

@ -0,0 +1,11 @@
{
"*": ["editorconfig-checker"],
"*.{js,ts,jsx,tsx}": [
"prettier --write",
"eslint --fix",
"jest --findRelatedTests"
],
"*.{yml,json}": ["prettier --write"],
"*.{md}": ["prettier --write", "markdownlint --dot --fix"],
"./Dockerfile": ["dockerfilelint"]
}

View File

@ -1,7 +1,6 @@
{ {
"default": true, "default": true,
"MD013": false, "MD013": false,
"MD024": false,
"MD033": false, "MD033": false,
"MD041": false "MD041": false
} }

5
.prettierignore Normal file
View File

@ -0,0 +1,5 @@
build
node_modules
coverage
package.json
package-lock.json

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"trailingComma": "none"
}

37
.releaserc.json Normal file
View File

@ -0,0 +1,37 @@
{
"branches": ["master"],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/npm",
{
"npmPublish": false
}
],
[
"@semantic-release/git",
{
"assets": ["package.json", "package-lock.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
"@semantic-release/github",
[
"@saithodev/semantic-release-backmerge",
{
"backmergeStrategy": "merge"
}
]
]
}

11
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"recommendations": [
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"davidanson.vscode-markdownlint",
"prisma.prisma",
"mikestead.dotenv",
"ms-azuretools.vscode-docker"
]
}

35
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,35 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"prettier.configPath": ".prettierrc.json",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"[markdown]": {
"editor.autoClosingBrackets": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.autoClosingBrackets": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.autoClosingBrackets": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.autoClosingBrackets": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.autoClosingBrackets": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.autoClosingBrackets": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
}
}

132
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,132 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
contact@divlo.fr.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][mozilla coc].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][faq]. Translations are available
at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[mozilla coc]: https://github.com/mozilla/diversity
[faq]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

View File

@ -4,7 +4,7 @@ Thanks a lot for your interest in contributing to **Thream/api**! 🎉
## Code of Conduct ## Code of Conduct
**Thream** has adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](https://github.com/Thream/Thream/blob/master/.github/CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated. **Thream** has adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](./CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated.
## Open Development ## Open Development
@ -14,14 +14,14 @@ All work on **Thream/api** happens directly on [GitHub](https://github.com/Threa
- Reporting a bug. - Reporting a bug.
- Suggest a new feature idea. - Suggest a new feature idea.
- Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...). - Correct spelling errors, improvements or additions to documentation files.
- Improve structure/format/performance/refactor/tests of the code. - Improve structure/format/performance/refactor/tests of the code.
## Pull Requests ## Pull Requests
- **Please first discuss** the change you wish to make via [issue](https://github.com/Thream/api/issues) before making a change. It might avoid a waste of your time. - **Please first discuss** the change you wish to make via issues.
- Ensure your code respect [Typescript Standard Style](https://www.npmjs.com/package/ts-standard). - Ensure your code respect `eslint` and `prettier`.
- Make sure your **code passes the tests**. - Make sure your **code passes the tests**.
@ -29,7 +29,9 @@ If you're adding new features to **Thream/api**, please include tests.
## Commits ## Commits
The commit message guidelines respect [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) and [Semantic Versioning](https://semver.org/) for releases. The commit message guidelines respect
[@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional)
and [Semantic Versioning](https://semver.org/) for releases.
### Types ### Types
@ -56,17 +58,16 @@ Scopes define what part of the code changed.
### Examples ### Examples
```sh ```sh
git commit -m "feat(users): add POST /users/signup" git commit -m "feat(services): add POST /users/signup"
git commit -m "docs(readme): update installation process" git commit -m "docs(readme): update installation process"
git commit -m "fix(messages): should emit events to connected users" git commit -m "fix(services): should emit events to connected users"
``` ```
## Directory Structure ## Directory Structure
```text ```text
├── email ├── email
├── public ├── prisma
├── scripts
└── src └── src
├── models ├── models
├── services ├── services
@ -77,8 +78,9 @@ git commit -m "fix(messages): should emit events to connected users"
### Each folder explained ### Each folder explained
- `email` : email template(s) and translation(s) - `email` : email template(s) and translation(s)
- `prisma` : contains the prisma schema and migrations
- `src` : all source files - `src` : all source files
- `models` : models that represent tables in database (there is a `_data.sql` file to have dummy data to work with in development mode) - `models` : models that represent tables in database as JSON schema
- `services` : all REST API endpoints - `services` : all REST API endpoints
- `tools` : configs and utilities - `tools` : configs and utilities
- `typings` : types gloablly used in the project - `typings` : types gloablly used in the project
@ -94,14 +96,9 @@ Here is what potentially look like a folder structure for this service :
└── src └── src
└── services └── services
└── channels └── channels
├── __docs__
│ └── get.yaml
├── __test__ ├── __test__
│ └── get.test.ts │ └── get.test.ts
├── [channelId] ├── [channelId]
│ ├── __docs__
│ │ ├── delete.yaml
│ │ └── put.yaml
│ ├── __test__ │ ├── __test__
│ │ ├── delete.test.ts │ │ ├── delete.test.ts
│ │ └── put.test.ts │ │ └── put.test.ts
@ -118,6 +115,7 @@ This folder structure will map to these REST API routes :
- DELETE `/channels/:channelId` - DELETE `/channels/:channelId`
- PUT `/channels/:channelId` - PUT `/channels/:channelId`
The folders after `src/services` : is the real path of the routes in the API except folders starting and ending with `__` like `__docs__`, `__test__` or `__utils__`. The folders after `src/services` : is the real path of the routes in the API except
folders starting and ending with `__` like `__test__` or `__utils__`.
The filenames correspond to the HTTP methods used (`get`, `post`, `put`, `delete`). The filenames correspond to the HTTP methods used (`get`, `post`, `put`, `delete`).

View File

@ -1,11 +1,22 @@
FROM node:14.16.1 FROM node:16.11.0 AS dependencies
RUN npm install --global npm@7 WORKDIR /usr/src/app
WORKDIR /api
COPY ./package*.json ./ COPY ./package*.json ./
RUN npm install RUN npm clean-install
FROM node:16.11.0 AS builder
WORKDIR /usr/src/app
COPY --from=dependencies /usr/src/app/node_modules ./node_modules
COPY ./ ./ COPY ./ ./
RUN npx prisma generate
RUN npm run build RUN npm run build
CMD ["npm", "run", "dev"] FROM node:16.11.0 AS runner
WORKDIR /usr/src/app
ENV NODE_ENV=production
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/email ./email
COPY --from=builder /usr/src/app/build ./build
COPY --from=builder /usr/src/app/prisma ./prisma
COPY --from=builder /usr/src/app/uploads ./uploads
USER node
CMD npm run prisma:migrate:deploy && node build/index.js

View File

@ -1,27 +0,0 @@
ARG NODE_VERSION=14.16.1
FROM node:${NODE_VERSION} AS dependencies
RUN npm install --global npm@7
WORKDIR /api
COPY ./package*.json ./
RUN npm clean-install
FROM node:${NODE_VERSION} AS builder
WORKDIR /api
COPY ./ ./
COPY --from=dependencies /api/node_modules ./node_modules
RUN npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /api
ENV NODE_ENV=production
COPY --from=builder /api/node_modules ./node_modules
COPY --from=builder /api/build ./build
COPY --from=builder /api/email ./email
COPY --from=builder /api/uploads ./uploads
RUN chown --recursive node /api/build
USER node
CMD ["node", "build/index.js"]

View File

@ -1,8 +1,4 @@
<h1 align="center"><a href="https://api.thream.divlo.fr/docs">Thream/api</a></h1> <h1 align="center"><a href="https://api.thream.divlo.fr/documentation">Thream/api</a></h1>
<p align="center">
<strong>Thream's application programming interface to stay close with your friends and communities.</strong>
</p>
<p align="center"> <p align="center">
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a> <a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
@ -14,8 +10,8 @@
<a href="https://github.com/Thream/api/actions/workflows/lint.yml"><img src="https://github.com/Thream/api/actions/workflows/lint.yml/badge.svg?branch=develop" /></a> <a href="https://github.com/Thream/api/actions/workflows/lint.yml"><img src="https://github.com/Thream/api/actions/workflows/lint.yml/badge.svg?branch=develop" /></a>
<a href="https://github.com/Thream/api/actions/workflows/test.yml"><img src="https://github.com/Thream/api/actions/workflows/test.yml/badge.svg?branch=develop" /></a> <a href="https://github.com/Thream/api/actions/workflows/test.yml"><img src="https://github.com/Thream/api/actions/workflows/test.yml/badge.svg?branch=develop" /></a>
<br /> <br />
<a href="https://www.npmjs.com/package/ts-standard"><img alt="TypeScript Standard Style" src="https://camo.githubusercontent.com/f87caadb70f384c0361ec72ccf07714ef69a5c0a/68747470733a2f2f62616467656e2e6e65742f62616467652f636f64652532307374796c652f74732d7374616e646172642f626c75653f69636f6e3d74797065736372697074"/></a>
<a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a> <a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a>
<a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a>
<a href="https://dependabot.com/"><img src="https://badgen.net/github/dependabot/Thream/api?icon=dependabot" alt="Dependabot badge" /></a> <a href="https://dependabot.com/"><img src="https://badgen.net/github/dependabot/Thream/api?icon=dependabot" alt="Dependabot badge" /></a>
</p> </p>
@ -29,9 +25,9 @@ This project was bootstrapped with [create-fullstack-app](https://github.com/Div
### Prerequisites ### Prerequisites
- [Node.js](https://nodejs.org/) >= 14 - [Node.js](https://nodejs.org/) >= 16.0.0
- [npm](https://www.npmjs.com/) >= 6 - [npm](https://www.npmjs.com/) >= 8.0.0
- [MySQL](https://www.mysql.com/) >= 8 - [PostgreSQL](https://www.postgresql.org/)
### Installation ### Installation
@ -45,41 +41,60 @@ cd api
# Configure environment variables # Configure environment variables
cp .env.example .env cp .env.example .env
# Install dependencies # Install
npm install npm install
``` ```
You will need to configure the environment variables by creating an `.env` file at the root of the project (see `.env.example`). You will need to configure the environment variables by creating an `.env` file at
the root of the project (see `.env.example`).
### Development environment with [Docker](https://www.docker.com/) ### Local Development environment
#### Setup the database
```sh ```sh
# Setup and run all the services for you # Create a new user and database
docker-compose up psql
create database thream_database;
create user thream_user with encrypted password 'password';
ALTER USER thream_user WITH SUPERUSER;
```
Replace `DATABASE_URL` inside `.env` with `postgresql://thream_user:password@localhost:5432/thream_database`
```sh
# Run Prisma migrations
npm run prisma:migrate:dev
```
#### Usage
```sh
# Run API
npm run dev
# Run Prisma Studio
npm run prisma:studio
``` ```
### Production environment with [Docker](https://www.docker.com/) ### Production environment with [Docker](https://www.docker.com/)
```sh ```sh
# Setup and run all the services for you # Setup and run all the services for you
docker-compose --file=docker-compose.production.yml up docker-compose up --build
``` ```
#### Services started #### Services started
- API : `http://localhost:8080` - API : `http://localhost:8080`
- [MySQL database](https://www.mysql.com/) - [PostgreSQL database](https://www.postgresql.org/)
#### Services started only in Development environment
- [phpmyadmin](https://www.phpmyadmin.net/) : `http://localhost:8000`
- [MailDev](https://maildev.github.io/maildev/) : `http://localhost:1080`
## 💡 Contributing ## 💡 Contributing
Anyone can help to improve the project, submit a Feature Request, a bug report or even correct a simple spelling mistake. Anyone can help to improve the project, submit a Feature Request, a bug report or
even correct a simple spelling mistake.
The steps to contribute can be found in the [CONTRIBUTING.md](./CONTRIBUTING.md) file. The steps to contribute can be found in [CONTRIBUTING.md](./CONTRIBUTING.md).
## 📄 License ## 📄 License

View File

@ -1,34 +0,0 @@
version: '3.0'
services:
thream-api:
container_name: ${COMPOSE_PROJECT_NAME}
build:
context: './'
dockerfile: './Dockerfile.production'
environment:
PORT: ${PORT}
env_file: './.env'
ports:
- '${PORT}:${PORT}'
depends_on:
- ${DATABASE_HOST}
volumes:
- './uploads:/api/uploads'
restart: 'unless-stopped'
thream-database:
container_name: ${DATABASE_HOST}
image: 'mysql:8.0.23'
command: '--default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci'
environment:
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD}
MYSQL_DATABASE: ${DATABASE_NAME}
MYSQL_TCP_PORT: ${DATABASE_PORT}
ports:
- '${DATABASE_PORT}:${DATABASE_PORT}'
volumes:
- 'database-volume:/var/lib/mysql'
restart: 'unless-stopped'
volumes:
database-volume:

View File

@ -4,48 +4,25 @@ services:
container_name: ${COMPOSE_PROJECT_NAME} container_name: ${COMPOSE_PROJECT_NAME}
build: build:
context: './' context: './'
env_file:
- '.env'
ports: ports:
- '${PORT}:${PORT}' - '${PORT}:${PORT}'
depends_on: depends_on:
- ${DATABASE_HOST} - 'thream-database'
- 'thream-maildev'
volumes: volumes:
- './:/api' - './uploads:/usr/src/app/uploads'
restart: 'unless-stopped'
thream-phpmyadmin:
container_name: 'thream-phpmyadmin'
image: 'phpmyadmin/phpmyadmin:5.0.4'
environment:
PMA_HOST: ${DATABASE_HOST}
PMA_PORT: ${DATABASE_PORT}
PMA_USER: ${DATABASE_USER}
PMA_PASSWORD: ${DATABASE_PASSWORD}
ports:
- '8000:80'
depends_on:
- ${DATABASE_HOST}
restart: 'unless-stopped' restart: 'unless-stopped'
thream-database: thream-database:
container_name: ${DATABASE_HOST} container_name: 'thream-database'
image: 'mysql:8.0.23' image: 'postgres:14.0'
command: '--default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci'
environment: environment:
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_USER: 'user'
MYSQL_DATABASE: ${DATABASE_NAME} POSTGRES_PASSWORD: 'password'
MYSQL_TCP_PORT: ${DATABASE_PORT} POSTGRES_DB: 'thream'
ports:
- '${DATABASE_PORT}:${DATABASE_PORT}'
volumes: volumes:
- 'database-volume:/var/lib/mysql' - 'database-volume:/var/lib/postgresql/data'
restart: 'unless-stopped'
thream-maildev:
container_name: 'thream-maildev'
image: 'maildev/maildev:1.1.0'
ports:
- '1080:80'
restart: 'unless-stopped' restart: 'unless-stopped'
volumes: volumes:

View File

@ -0,0 +1,53 @@
/** @type {import('node-plop').PlopGeneratorConfig} */
exports.serviceGenerator = {
description: 'REST API endpoint',
prompts: [
{
type: 'input',
name: 'url',
message: 'url'
},
{
type: 'list',
name: 'httpMethod',
message: 'httpMethod',
choices: ['GET', 'POST', 'PUT', 'DELETE']
},
{
type: 'input',
name: 'description',
message: 'description'
},
{
type: 'list',
name: 'tag',
message: 'tag',
choices: [
'users',
'guilds',
'channels',
'invitations',
'messages',
'members',
'uploads'
]
},
{
type: 'confirm',
name: 'shouldBeAuthenticated',
message: 'shouldBeAuthenticated'
}
],
actions: [
{
type: 'add',
path: 'src/services/{{url}}/{{lowerCase httpMethod}}.ts',
templateFile: 'generators/service/service.ts.hbs'
},
{
type: 'add',
path: 'src/services/{{url}}/__test__/{{lowerCase httpMethod}}.test.ts',
templateFile: 'generators/service/service.test.ts.hbs'
}
]
}

View File

@ -0,0 +1,26 @@
import { application } from 'application.js'
{{#if shouldBeAuthenticated}}
import { authenticateUserTest } from '__test__/utils/authenticateUserTest.js'
{{/if}}
import { prismaMock } from '__test__/setup.js'
describe('{{httpMethod}} {{url}}', () => {
it('succeeds', async () => {
// prismaMock.service.findUnique.mockResolvedValue(null)
{{#if shouldBeAuthenticated}}
const { accessToken, user } = await authenticateUserTest()
{{/if}}
const response = await application.inject({
method: '{{httpMethod}}',
url: '{{url}}',
{{#if shouldBeAuthenticated}}
headers: {
authorization: `Bearer ${accessToken}`
},
{{/if}}
payload: {}
})
// const responseJson = response.json()
expect(response.statusCode).toEqual(200)
})
})

View File

@ -0,0 +1,59 @@
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import prisma from 'tools/database/prisma.js'
import { fastifyErrors } from 'models/utils.js'
{{#if shouldBeAuthenticated}}
import authenticateUser from 'tools/plugins/authenticateUser.js'
{{/if}}
const body{{sentenceCase httpMethod}}ServiceSchema = Type.Object({
property: Type.String()
})
type Body{{sentenceCase httpMethod}}ServiceSchemaType = Static<typeof body{{sentenceCase httpMethod}}ServiceSchema>
const {{lowerCase httpMethod}}ServiceSchema: FastifySchema = {
description: '{{description}}',
tags: ['{{tag}}'] as string[],
{{#if shouldBeAuthenticated}}
security: [
{
bearerAuth: []
}
] as Array<{ [key: string]: [] }>,
{{/if}}
body: body{{sentenceCase httpMethod}}ServiceSchema,
response: {
200: Type.Object({}),
400: fastifyErrors[400],
{{#if shouldBeAuthenticated}}
401: fastifyErrors[401],
403: fastifyErrors[403],
{{/if}}
500: fastifyErrors[500]
}
} as const
export const {{lowerCase httpMethod}}Service: FastifyPluginAsync = async (fastify) => {
{{#if shouldBeAuthenticated}}
await fastify.register(authenticateUser)
{{/if}}
fastify.route<{
Body: Body{{sentenceCase httpMethod}}ServiceSchemaType
}>({
method: '{{httpMethod}}',
url: '{{url}}',
schema: {{lowerCase httpMethod}}ServiceSchema,
handler: async (request, reply) => {
{{#if shouldBeAuthenticated}}
if (request.user == null) {
throw fastify.httpErrors.forbidden()
}
{{/if}}
reply.statusCode = 200
return {}
}
})
}

11
jest.config.json Normal file
View File

@ -0,0 +1,11 @@
{
"preset": "ts-jest",
"testEnvironment": "node",
"resolver": "jest-ts-webcompat-resolver",
"setupFiles": ["./__test__/setEnvironmentsVariables.ts"],
"setupFilesAfterEnv": ["./__test__/setup.ts"],
"rootDir": "./src",
"collectCoverage": true,
"coverageDirectory": "../coverage/",
"coverageReporters": ["text", "cobertura"]
}

29099
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,146 +1,98 @@
{ {
"name": "@thream/api", "name": "@thream/api",
"version": "0.0.0-development", "version": "0.0.1",
"description": "Thream's application programming interface to stay close with your friends and communities.",
"private": true, "private": true,
"release-it": { "repository": {
"git": { "type": "git",
"commit": false, "url": "https://github.com/Thream/api"
"push": false,
"tag": false
}, },
"gitlab": { "engines": {
"release": false "node": ">=16.0.0",
}, "npm": ">=8.0.0"
"npm": {
"publish": false
},
"hooks": {
"before:init": [
"npm run lint:docker",
"npm run lint:editorconfig",
"npm run lint:markdown",
"npm run lint:typescript",
"npm run build",
"npm run test"
]
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "angular",
"infile": "CHANGELOG.md"
}
}
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"setupFiles": [
"./__test__/setEnvsVars.ts"
],
"setupFilesAfterEnv": [
"./__test__/setup.ts"
],
"rootDir": "./src",
"collectCoverage": true,
"coverageDirectory": "../coverage/",
"coverageReporters": [
"text",
"cobertura"
]
},
"ts-standard": {
"ignore": [
"build",
"coverage",
"node_modules",
"uploads"
],
"envs": [
"node",
"jest"
],
"report": "stylish"
}, },
"scripts": { "scripts": {
"build": "rimraf ./build && tsc", "build": "rimraf ./build && tsc",
"start": "cross-env NODE_ENV=production node build/index.js", "start": "cross-env NODE_ENV=production node build/index.js",
"dev": "concurrently --kill-others --names \"TypeScript,Node\" --prefix \"[{name}]\" --prefix-colors \"blue,green\" \"tsc --watch\" \"cross-env NODE_ENV=development nodemon -e js,json,yaml build/index.js\"", "dev": "concurrently -k -n \"TypeScript,Node\" -p \"[{name}]\" -c \"blue,green\" \"tsc --watch\" \"cross-env NODE_ENV=development nodemon -e js,json,yaml build/index.js\"",
"generate": "plop",
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:docker": "dockerfilelint './Dockerfile' && dockerfilelint './Dockerfile.production'", "lint:docker": "dockerfilelint './Dockerfile'",
"lint:editorconfig": "editorconfig-checker", "lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint '**/*.md' --dot --ignore node_modules", "lint:markdown": "markdownlint '**/*.md' --dot --ignore 'node_modules'",
"lint:typescript": "ts-standard", "lint:typescript": "eslint '**/*.{js,ts,jsx,tsx}'",
"release": "release-it", "lint:staged": "lint-staged",
"test": "jest", "test": "jest",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"release": "semantic-release",
"postinstall": "husky install" "postinstall": "husky install"
}, },
"dependencies": { "dependencies": {
"@thream/socketio-jwt": "2.1.0", "@prisma/client": "3.2.1",
"axios": "0.21.1", "@sinclair/typebox": "0.20.5",
"axios": "0.22.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"cors": "2.8.5", "dotenv": "10.0.0",
"dotenv": "8.2.0",
"ejs": "3.1.6", "ejs": "3.1.6",
"express": "4.17.1", "fastify": "3.22.0",
"express-async-errors": "3.1.1", "fastify-cors": "6.0.2",
"express-fileupload": "1.2.1", "fastify-helmet": "5.3.2",
"express-rate-limit": "5.2.6", "fastify-multipart": "5.0.2",
"express-validator": "6.10.0", "fastify-plugin": "3.0.0",
"helmet": "4.5.0", "fastify-rate-limit": "5.6.2",
"fastify-sensible": "3.1.1",
"fastify-static": "4.4.0",
"fastify-swagger": "4.12.4",
"fastify-url-data": "3.0.3",
"http-errors": "1.8.0",
"jsonwebtoken": "8.5.1", "jsonwebtoken": "8.5.1",
"morgan": "1.10.0",
"ms": "2.1.3", "ms": "2.1.3",
"mysql2": "2.2.5", "nodemailer": "6.6.5",
"nodemailer": "6.5.0", "read-pkg": "5.2.0",
"reflect-metadata": "0.1.13", "socket.io": "4.2.0"
"sequelize": "6.6.2",
"sequelize-typescript": "2.1.0",
"socket.io": "4.0.1",
"swagger-jsdoc": "6.1.0",
"swagger-ui-express": "4.1.6",
"uuid": "8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "12.1.1", "@commitlint/cli": "13.2.1",
"@commitlint/config-conventional": "12.1.1", "@commitlint/config-conventional": "13.2.0",
"@release-it/conventional-changelog": "2.0.1", "@saithodev/semantic-release-backmerge": "1.5.3",
"@types/bcryptjs": "2.4.2", "@types/bcryptjs": "2.4.2",
"@types/cors": "2.8.10", "@types/busboy": "0.3.0",
"@types/ejs": "3.0.6", "@types/ejs": "3.1.0",
"@types/express": "4.17.11", "@types/http-errors": "1.8.1",
"@types/express-fileupload": "1.1.6", "@types/jest": "27.0.2",
"@types/express-rate-limit": "5.1.1", "@types/jsonwebtoken": "8.5.5",
"@types/jest": "26.0.22",
"@types/jsonwebtoken": "8.5.1",
"@types/mock-fs": "4.13.0",
"@types/morgan": "1.9.2",
"@types/ms": "0.7.31", "@types/ms": "0.7.31",
"@types/node": "14.14.41", "@types/node": "16.10.3",
"@types/nodemailer": "6.4.1", "@types/nodemailer": "6.4.4",
"@types/server-destroy": "1.0.1", "@typescript-eslint/eslint-plugin": "4.33.0",
"@types/supertest": "2.0.11", "concurrently": "6.3.0",
"@types/swagger-jsdoc": "6.0.0",
"@types/swagger-ui-express": "4.1.2",
"@types/uuid": "8.3.0",
"@types/validator": "13.1.3",
"concurrently": "6.0.2",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"dockerfilelint": "1.8.0", "dockerfilelint": "1.8.0",
"editorconfig-checker": "4.0.2", "editorconfig-checker": "4.0.2",
"husky": "6.0.0", "eslint": "7.32.0",
"jest": "26.6.3", "eslint-config-prettier": "8.3.0",
"markdownlint-cli": "0.27.1", "eslint-config-standard-with-typescript": "21.0.1",
"mock-fs": "4.13.0", "eslint-plugin-import": "2.24.2",
"nodemon": "2.0.7", "eslint-plugin-node": "11.1.0",
"release-it": "14.6.1", "eslint-plugin-prettier": "4.0.0",
"eslint-plugin-promise": "5.1.0",
"eslint-plugin-unicorn": "36.0.0",
"husky": "7.0.2",
"jest": "27.2.5",
"jest-mock-extended": "2.0.4",
"jest-ts-webcompat-resolver": "1.0.0",
"lint-staged": "11.2.1",
"markdownlint-cli": "0.29.0",
"nodemon": "2.0.13",
"plop": "2.7.4",
"prettier": "2.4.1",
"prisma": "3.2.1",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"server-destroy": "1.0.1", "semantic-release": "18.0.0",
"socket.io-client": "4.0.1", "ts-jest": "27.0.5",
"sqlite": "4.0.21", "typescript": "4.4.3"
"sqlite3": "5.0.2",
"supertest": "6.1.3",
"ts-jest": "26.5.5",
"ts-standard": "10.0.0",
"typescript": "4.2.4"
} }
} }

8
plopfile.js Normal file
View File

@ -0,0 +1,8 @@
const { serviceGenerator } = require('./generators/service/index.js')
module.exports = (
/** @type {import('plop').NodePlopAPI} */
plop
) => {
plop.setGenerator('service', serviceGenerator)
}

View File

@ -0,0 +1,137 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"name" VARCHAR(255) NOT NULL,
"email" VARCHAR(255),
"password" TEXT,
"logo" TEXT,
"status" VARCHAR(255),
"biography" TEXT,
"website" VARCHAR(255),
"isConfirmed" BOOLEAN NOT NULL DEFAULT false,
"temporaryToken" TEXT,
"temporaryExpirationToken" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserSetting" (
"id" SERIAL NOT NULL,
"language" VARCHAR(255) NOT NULL,
"theme" VARCHAR(255) NOT NULL,
"isPublicEmail" BOOLEAN NOT NULL DEFAULT false,
"isPublicGuilds" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
CONSTRAINT "UserSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuth" (
"id" SERIAL NOT NULL,
"providerId" TEXT NOT NULL,
"provider" VARCHAR(255) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
CONSTRAINT "OAuth_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Member" (
"id" SERIAL NOT NULL,
"isOwner" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
"guildId" INTEGER NOT NULL,
CONSTRAINT "Member_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Guild" (
"id" SERIAL NOT NULL,
"name" VARCHAR(255) NOT NULL,
"icon" TEXT,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Channel" (
"id" SERIAL NOT NULL,
"name" VARCHAR(255) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"guildId" INTEGER NOT NULL,
CONSTRAINT "Channel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" SERIAL NOT NULL,
"value" TEXT NOT NULL,
"type" VARCHAR(255) NOT NULL DEFAULT E'text',
"mimetype" VARCHAR(255) NOT NULL DEFAULT E'text/plain',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"memberId" INTEGER NOT NULL,
"channelId" INTEGER NOT NULL,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_name_key" ON "User"("name");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "UserSetting_userId_key" ON "UserSetting"("userId");
-- AddForeignKey
ALTER TABLE "UserSetting" ADD CONSTRAINT "UserSetting_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Member" ADD CONSTRAINT "Member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Member" ADD CONSTRAINT "Member_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Channel" ADD CONSTRAINT "Channel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

105
prisma/schema.prisma Normal file
View File

@ -0,0 +1,105 @@
datasource db {
provider = "postgres"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(255)
email String? @unique @db.VarChar(255)
password String? @db.Text
logo String? @db.Text
status String? @db.VarChar(255)
biography String? @db.Text
website String? @db.VarChar(255)
isConfirmed Boolean @default(false)
temporaryToken String?
temporaryExpirationToken DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
settings UserSetting?
refreshTokens RefreshToken[]
oauths OAuth[]
members Member[]
}
model UserSetting {
id Int @id @default(autoincrement())
language String @default("en") @db.VarChar(255)
theme String @default("dark") @db.VarChar(255)
isPublicEmail Boolean @default(false)
isPublicGuilds Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId Int @unique
user User? @relation(fields: [userId], references: [id])
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId Int @unique
user User? @relation(fields: [userId], references: [id])
}
model OAuth {
id Int @id @default(autoincrement())
providerId String @db.Text
provider String @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId Int @unique
user User? @relation(fields: [userId], references: [id])
}
model Member {
id Int @id @default(autoincrement())
isOwner Boolean @default(false)
Message Message[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId Int @unique
user User? @relation(fields: [userId], references: [id])
guildId Int @unique
guild Guild? @relation(fields: [guildId], references: [id])
}
model Guild {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
icon String? @db.Text
description String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
members Member[]
channels Channel[]
}
model Channel {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
guildId Int @unique
guild Guild? @relation(fields: [guildId], references: [id])
messages Message[]
}
model Message {
id Int @id @default(autoincrement())
value String @db.Text
type String @default("text") @db.VarChar(255)
mimetype String @default("text/plain") @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
memberId Int @unique
member Member? @relation(fields: [memberId], references: [id])
channelId Int @unique
channel Channel? @relation(fields: [channelId], references: [id])
}

View File

@ -1,17 +0,0 @@
import request from 'supertest'
import application from '../application'
import { usersLogoPath } from '../tools/configurations/constants'
describe('application', () => {
it("returns a 404 on route that doesn't exist", async () => {
return await request(application).post('/404routenotfound').send().expect(404)
})
it('returns a 200 success code for users images', async () => {
return await request(application)
.get(`${usersLogoPath.name}/default.png`)
.send()
.expect(200)
})
})

View File

@ -1,4 +1,3 @@
process.env.DATABASE_DIALECT = 'sqlite'
process.env.JWT_ACCESS_EXPIRES_IN = '15 minutes' process.env.JWT_ACCESS_EXPIRES_IN = '15 minutes'
process.env.JWT_ACCESS_SECRET = 'accessTokenSecret' process.env.JWT_ACCESS_SECRET = 'accessTokenSecret'
process.env.JWT_REFRESH_SECRET = 'refreshTokenSecret' process.env.JWT_REFRESH_SECRET = 'refreshTokenSecret'

View File

@ -1,11 +1,8 @@
import fsMock from 'mock-fs' import { PrismaClient } from '@prisma/client'
import path from 'path' import { mockDeep, mockReset } from 'jest-mock-extended'
import { Sequelize } from 'sequelize-typescript' import { DeepMockProxy } from 'jest-mock-extended/lib/cjs/Mock'
import { Database, open } from 'sqlite'
import sqlite3 from 'sqlite3'
let sqlite: Database | undefined import prisma from '../tools/database/prisma.js'
let sequelize: Sequelize | undefined
jest.mock('nodemailer', () => ({ jest.mock('nodemailer', () => ({
createTransport: () => { createTransport: () => {
@ -15,28 +12,13 @@ jest.mock('nodemailer', () => ({
} }
})) }))
beforeAll(async () => { jest.mock('../tools/database/prisma.js', () => ({
sqlite = await open({ __esModule: true,
filename: ':memory:', default: mockDeep<PrismaClient>()
driver: sqlite3.Database }))
})
sequelize = new Sequelize({ beforeEach(() => {
dialect: process.env.DATABASE_DIALECT, mockReset(prismaMock)
storage: process.env.DATABASE_DIALECT === 'sqlite' ? ':memory:' : undefined,
logging: false,
models: [path.join(__dirname, '..', 'models')]
})
}) })
beforeEach(async () => { export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>
await sequelize?.sync({ force: true })
})
afterEach(async () => {
fsMock.restore()
})
afterAll(async () => {
await sqlite?.close()
await sequelize?.close()
})

View File

@ -1,10 +0,0 @@
import { formatErrors } from '../formatErrors'
test('__test__/utils/formatErrors', () => {
expect(formatErrors('randomSring')).toEqual([])
const errors = [
{ message: 'some error message' },
{ message: 'another error' }
]
expect(formatErrors(errors)).toEqual(['some error message', 'another error'])
})

View File

@ -1,57 +0,0 @@
import request from 'supertest'
import application from '../../application'
import User from '../../models/User'
interface AuthenticateUserOptions {
name?: string
email?: string
password?: string
shouldBeConfirmed?: boolean
alreadySignedUp?: boolean
}
export async function authenticateUserTest (
options: AuthenticateUserOptions = {}
): Promise<{
accessToken: string
refreshToken: string
expiresIn: string
type: 'Bearer'
userId: number
}> {
const {
name = 'John',
email = 'contact@test.com',
shouldBeConfirmed = true,
password = 'test',
alreadySignedUp = false
} = options
if (!alreadySignedUp) {
const { body: signupBody } = await request(application)
.post('/users/signup')
.send({ name, email, password })
.expect(201)
let signinResponse: any = { body: {} }
if (shouldBeConfirmed) {
const user = await User.findOne({ where: { id: signupBody.user.id } })
await request(application)
.get(`/users/confirmEmail?tempToken=${user?.tempToken as string}`)
.send()
.expect(200)
signinResponse = await request(application)
.post('/users/signin')
.send({ email, password })
.expect(200)
}
return { ...signinResponse.body, userId: signupBody.user.id }
}
const signinResponse = await request(application)
.post('/users/signin')
.send({ email, password })
.expect(200)
const user = await User.findOne({ where: { email } })
return { ...signinResponse.body, userId: user?.id }
}

View File

@ -0,0 +1,28 @@
import { User } from '@prisma/client'
import { refreshTokenExample } from '../../models/RefreshToken.js'
import { userExample, UserJWT } from '../../models/User.js'
import { userSettingsExample } from '../../models/UserSettings.js'
import {
generateAccessToken,
generateRefreshToken
} from '../../tools/utils/jwtToken'
import { prismaMock } from '../setup'
export const authenticateUserTest = async (): Promise<{
accessToken: string
refreshToken: string
user: User
}> => {
prismaMock.user.findUnique.mockResolvedValue(userExample)
prismaMock.userSetting.findFirst.mockResolvedValue(userSettingsExample)
prismaMock.oAuth.findMany.mockResolvedValue([])
prismaMock.refreshToken.create.mockResolvedValue(refreshTokenExample)
const userJWT: UserJWT = {
currentStrategy: 'local',
id: 1
}
const accessToken = generateAccessToken(userJWT)
const refreshToken = await generateRefreshToken(userJWT)
return { accessToken, refreshToken, user: userExample }
}

View File

@ -1,8 +0,0 @@
/** formatErrors for testing purpose (no types safety) */
export const formatErrors = (errors: any): string[] => {
try {
return errors.map((e: any) => e.message)
} catch {
return []
}
}

View File

@ -1,5 +0,0 @@
export const wait = async (ms: number): Promise<void> => {
return await new Promise((resolve) => {
setTimeout(resolve, ms)
})
}

View File

@ -1,45 +1,51 @@
import 'express-async-errors' import { fileURLToPath } from 'node:url'
import cors from 'cors'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import express, { Request } from 'express' import fastify from 'fastify'
import rateLimit from 'express-rate-limit' import fastifyCors from 'fastify-cors'
import helmet from 'helmet' import fastifySwagger from 'fastify-swagger'
import morgan from 'morgan' import fastifyUrlData from 'fastify-url-data'
import fastifyHelmet from 'fastify-helmet'
import fastifyRateLimit from 'fastify-rate-limit'
import fastifySensible from 'fastify-sensible'
import fastifyStatic from 'fastify-static'
import { errorHandler } from './tools/middlewares/errorHandler' import { services } from './services/index.js'
import { router } from './services' import { swaggerOptions } from './tools/configurations/swaggerOptions.js'
import { NotFoundError } from './tools/errors/NotFoundError' import fastifySocketIo from './tools/plugins/socket-io.js'
import { TooManyRequestsError } from './tools/errors/TooManyRequestsError' import { UPLOADS_URL } from './tools/configurations/index.js'
const application = express() export const application = fastify({
logger: process.env.NODE_ENV === 'development'
})
dotenv.config() dotenv.config()
if (process.env.NODE_ENV === 'development') { const main = async (): Promise<void> => {
application.use(morgan<Request>('dev')) await application.register(fastifyCors)
} else if (process.env.NODE_ENV === 'production') { await application.register(fastifySensible)
const requestPerSecond = 2 await application.register(fastifyUrlData)
const seconds = 60 await application.register(fastifySocketIo, {
const windowMs = seconds * 1000 cors: {
application.enable('trust proxy') origin: '*',
application.use( methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
rateLimit({ preflightContinue: false,
windowMs, optionsSuccessStatus: 204
max: seconds * requestPerSecond,
handler: () => {
throw new TooManyRequestsError()
} }
}) })
) await application.register(fastifyHelmet)
await application.register(fastifyRateLimit, {
max: 100,
timeWindow: '1 minute'
})
await application.register(fastifyStatic, {
root: fileURLToPath(UPLOADS_URL),
prefix: '/uploads/'
})
await application.register(fastifySwagger, swaggerOptions)
await application.register(services)
} }
application.use(express.json()) main().catch((error) => {
application.use(helmet()) console.error(error)
application.use(cors<Request>()) process.exit(1)
application.use(router)
application.use(() => {
throw new NotFoundError()
}) })
application.use(errorHandler)
export default application

View File

@ -1,22 +1,12 @@
import { authorize } from '@thream/socketio-jwt' import { application } from './application.js'
import { HOST, PORT } from './tools/configurations/index.js'
import application from './application' const main = async (): Promise<void> => {
import { socket } from './tools/socket' const address = await application.listen(PORT, HOST)
import { sequelize } from './tools/database/sequelize' console.log('\x1b[36m%s\x1b[0m', `🚀 Server listening at ${address}`)
}
const PORT = parseInt(process.env.PORT ?? '8080', 10) main().catch((error) => {
console.error(error)
sequelize process.exit(1)
.sync() })
.then(() => {
const server = application.listen(PORT, () => {
console.log('\x1b[36m%s\x1b[0m', `🚀 Server listening on port ${PORT}.`)
})
socket.init(server)
socket.io?.use(
authorize({
secret: process.env.JWT_ACCESS_SECRET
})
)
})
.catch((error) => console.error(error))

View File

@ -1,55 +1,23 @@
import { import { Type } from '@sinclair/typebox'
BelongsTo, import { Channel } from '@prisma/client'
Column,
DataType,
ForeignKey,
HasMany,
Model,
Table
} from 'sequelize-typescript'
import Guild from './Guild' import { date, id } from './utils.js'
import Message from './Message' import { guildExample } from './Guild.js'
export const channelTypes = ['text', 'voice'] as const export const types = [Type.Literal('text')]
export type ChannelType = typeof channelTypes[number]
@Table export const channelSchema = {
export default class Channel extends Model { id,
@Column({ name: Type.String({ maxLength: 255 }),
type: DataType.STRING, createdAt: date.createdAt,
allowNull: false updatedAt: date.updatedAt,
}) guildId: id
name!: string }
@Column({ export const channelExample: Channel = {
type: DataType.STRING, id: 1,
allowNull: false, name: 'general',
defaultValue: 'text' guildId: guildExample.id,
}) createdAt: new Date(),
type!: ChannelType updatedAt: new Date()
@Column({
type: DataType.STRING,
allowNull: false,
defaultValue: ''
})
description!: string
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false
})
isDefault!: boolean
@ForeignKey(() => Guild)
@Column
guildId!: number
@BelongsTo(() => Guild)
guild!: Guild
@HasMany(() => Message)
messages!: Message[]
} }

View File

@ -1,45 +1,22 @@
import { Column, DataType, HasMany, Model, Table } from 'sequelize-typescript' import { Guild } from '@prisma/client'
import { guildsIconPath } from '../tools/configurations/constants' import { Type } from '@sinclair/typebox'
import Channel from './Channel' import { date, id } from './utils.js'
import Invitation from './Invitation'
import Member from './Member'
@Table export const guildSchema = {
export default class Guild extends Model { id,
@Column({ name: Type.String({ minLength: 3, maxLength: 30 }),
type: DataType.STRING, icon: Type.String({ format: 'uri-reference' }),
allowNull: false description: Type.String({ maxLength: 160 }),
}) createdAt: date.createdAt,
name!: string updatedAt: date.updatedAt
}
@Column({
type: DataType.STRING, export const guildExample: Guild = {
allowNull: false, id: 1,
defaultValue: '' name: 'GuildExample',
}) description: 'guild example.',
description!: string icon: null,
createdAt: new Date(),
@Column({ updatedAt: new Date()
type: DataType.TEXT,
allowNull: false,
defaultValue: `${guildsIconPath.name}/default.png`
})
icon!: string
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false
})
isPublic!: boolean
@HasMany(() => Member, { onDelete: 'CASCADE' })
members!: Member[]
@HasMany(() => Invitation, { onDelete: 'CASCADE' })
invitations!: Invitation[]
@HasMany(() => Channel)
channels!: Channel[]
} }

View File

@ -1,40 +0,0 @@
import {
BelongsTo,
Column,
DataType,
ForeignKey,
Model,
Table
} from 'sequelize-typescript'
import Guild from './Guild'
@Table
export default class Invitation extends Model {
@Column({
type: DataType.STRING,
allowNull: false
})
value!: string
@Column({
type: DataType.BIGINT,
allowNull: false
})
/** expiresIn is how long, in milliseconds, until the invitation expires. Note: 0 = never expires */
expiresIn!: number
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false
})
isPublic!: boolean
@ForeignKey(() => Guild)
@Column
guildId!: number
@BelongsTo(() => Guild, { onDelete: 'CASCADE' })
guild!: Guild
}

View File

@ -1,48 +1,24 @@
import { import { Type } from '@sinclair/typebox'
BelongsTo, import { Member } from '@prisma/client'
Column,
DataType,
ForeignKey,
HasMany,
Model,
Table
} from 'sequelize-typescript'
import Channel from './Channel' import { date, id } from './utils.js'
import Guild from './Guild' import { guildExample } from './Guild.js'
import Message from './Message' import { userExample } from './User.js'
import User from './User'
@Table export const memberSchema = {
export default class Member extends Model { id,
@Column({ isOwner: Type.Boolean({ default: false }),
type: DataType.BOOLEAN, createdAt: date.createdAt,
allowNull: false, updatedAt: date.updatedAt,
defaultValue: false userId: id,
}) guildId: id
isOwner!: boolean }
@ForeignKey(() => Channel) export const memberExample: Member = {
@Column id: 1,
lastVisitedChannelId!: number isOwner: true,
userId: userExample.id,
@BelongsTo(() => Channel) guildId: guildExample.id,
channel!: Channel createdAt: new Date(),
updatedAt: new Date()
@ForeignKey(() => User)
@Column
userId!: number
@BelongsTo(() => User)
user!: User
@ForeignKey(() => Guild)
@Column
guildId!: number
@BelongsTo(() => Guild)
guild!: Guild
@HasMany(() => Message, { onDelete: 'CASCADE' })
messages!: Message[]
} }

View File

@ -1,51 +1,20 @@
import { import { Type } from '@sinclair/typebox'
BelongsTo,
Column,
DataType,
ForeignKey,
Model,
Table
} from 'sequelize-typescript'
import Channel from './Channel' import { date, id } from './utils.js'
import Member from './Member'
export const messageTypes = ['text', 'file'] as const export const types = [Type.Literal('text'), Type.Literal('file')]
export type MessageType = typeof messageTypes[number]
@Table export const messageSchema = {
export default class Message extends Model { id,
@Column({ value: Type.String(),
type: DataType.TEXT, type: Type.Union(types, { default: 'text' }),
allowNull: false mimetype: Type.String({
}) maxLength: 255,
value!: string default: 'text/plain',
format: 'mimetype'
@Column({ }),
type: DataType.STRING, createdAt: date.createdAt,
allowNull: false, updatedAt: date.updatedAt,
defaultValue: 'text' memberId: id,
}) channelId: id
type!: MessageType
@Column({
type: DataType.STRING,
allowNull: false,
defaultValue: 'text/plain'
})
mimetype!: string
@ForeignKey(() => Member)
@Column
memberId!: number
@BelongsTo(() => Member)
member!: Member
@ForeignKey(() => Channel)
@Column
channelId!: number
@BelongsTo(() => Channel)
channel!: Channel
} }

View File

@ -1,38 +1,25 @@
import { import { Type } from '@sinclair/typebox'
BelongsTo,
Column,
DataType,
ForeignKey,
Model,
Table
} from 'sequelize-typescript'
import User from './User' import { date, id } from './utils.js'
export const providers = ['google', 'github', 'discord'] as const export const providers = ['google', 'github', 'discord'] as const
export const strategies = [...providers, 'local'] as const export const strategies = [...providers, 'local'] as const
export const strategiesTypebox = strategies.map((strategy) =>
Type.Literal(strategy)
)
export const providersTypebox = providers.map((provider) =>
Type.Literal(provider)
)
export type ProviderOAuth = typeof providers[number] export type ProviderOAuth = typeof providers[number]
export type AuthenticationStrategy = typeof strategies[number] export type AuthenticationStrategy = typeof strategies[number]
@Table export const oauthSchema = {
export default class OAuth extends Model { id,
@Column({ providerId: Type.String(),
type: DataType.STRING, provider: Type.Union([...providersTypebox]),
allowNull: false createdAt: date.createdAt,
}) updatedAt: date.updatedAt,
provider!: ProviderOAuth userId: id
@Column({
type: DataType.TEXT,
allowNull: false
})
providerId!: string
@ForeignKey(() => User)
@Column
userId!: number
@BelongsTo(() => User)
user!: User
} }

View File

@ -1,26 +1,21 @@
import { import { RefreshToken } from '@prisma/client'
BelongsTo, import { Type } from '@sinclair/typebox'
Column,
DataType,
ForeignKey,
Model,
Table
} from 'sequelize-typescript'
import User from './User' import { userExample } from './User.js'
import { date, id } from './utils.js'
@Table export const refreshTokensSchema = {
export default class RefreshToken extends Model { id,
@Column({ token: Type.String(),
type: DataType.TEXT, createdAt: date.createdAt,
allowNull: false updatedAt: date.updatedAt,
}) userId: id
token!: string }
@ForeignKey(() => User) export const refreshTokenExample: RefreshToken = {
@Column id: 1,
userId!: number userId: userExample.id,
token: 'sometoken',
@BelongsTo(() => User) createdAt: new Date(),
user!: User updatedAt: new Date()
} }

View File

@ -1,26 +1,9 @@
import { import { User } from '@prisma/client'
Column, import { Static, Type } from '@sinclair/typebox'
DataType,
HasMany,
HasOne,
Model,
Table
} from 'sequelize-typescript'
import Member from './Member' import { AuthenticationStrategy, strategiesTypebox } from './OAuth.js'
import OAuth, { AuthenticationStrategy } from './OAuth' import { userSettingsSchema } from './UserSettings.js'
import RefreshToken from './RefreshToken' import { date, id } from './utils.js'
import UserSetting from './UserSetting'
import { deleteObjectAttributes } from '../tools/utils/deleteObjectAttributes'
import { usersLogoPath } from '../tools/configurations/constants'
export const userHiddenAttributes = [
'password',
'tempToken',
'tempExpirationToken'
] as const
export type UserHiddenAttributes = typeof userHiddenAttributes[number]
export interface UserToJSON extends Omit<User, UserHiddenAttributes> {}
export interface UserJWT { export interface UserJWT {
id: number id: number
@ -33,80 +16,66 @@ export interface UserRequest {
accessToken: string accessToken: string
} }
@Table export const userSchema = {
export default class User extends Model { id,
@Column({ name: Type.String({ minLength: 1, maxLength: 30 }),
type: DataType.STRING, email: Type.String({ minLength: 1, maxLength: 255, format: 'email' }),
allowNull: false password: Type.String(),
}) logo: Type.String({ format: 'uri-reference' }),
name!: string status: Type.String({ maxLength: 255 }),
biography: Type.String(),
@Column({ website: Type.String({ maxLength: 255, format: 'uri-reference' }),
type: DataType.STRING, isConfirmed: Type.Boolean({ default: false }),
allowNull: true temporaryToken: Type.String(),
}) temporaryExpirationToken: Type.String({ format: 'date-time' }),
email?: string createdAt: date.createdAt,
updatedAt: date.updatedAt
@Column({ }
type: DataType.TEXT,
allowNull: true export const userPublicSchema = {
}) id,
password?: string name: userSchema.name,
email: Type.Optional(userSchema.email),
@Column({ logo: Type.Optional(userSchema.logo),
type: DataType.STRING, status: Type.Optional(userSchema.status),
allowNull: false, biography: Type.Optional(userSchema.biography),
defaultValue: '' website: Type.Optional(userSchema.website),
}) isConfirmed: userSchema.isConfirmed,
status!: string createdAt: date.createdAt,
updatedAt: date.updatedAt,
@Column({ settings: Type.Optional(Type.Object(userSettingsSchema))
type: DataType.STRING, }
allowNull: false,
defaultValue: '' export const userCurrentSchema = Type.Object({
}) user: Type.Object({
biography!: string ...userPublicSchema,
currentStrategy: Type.Union([...strategiesTypebox]),
@Column({ strategies: Type.Array(Type.Union([...strategiesTypebox]))
type: DataType.TEXT, })
allowNull: false, })
defaultValue: `${usersLogoPath.name}/default.png`
}) export const bodyUserSchema = Type.Object({
logo!: string email: userSchema.email,
name: userSchema.name,
@Column({ password: userSchema.password,
type: DataType.BOOLEAN, theme: userSettingsSchema.theme,
allowNull: false, language: userSettingsSchema.language
defaultValue: false })
})
isConfirmed!: boolean export type BodyUserSchemaType = Static<typeof bodyUserSchema>
@Column({ export const userExample: User = {
type: DataType.TEXT, id: 1,
allowNull: true name: 'Divlo',
}) email: 'contact@divlo.fr',
tempToken?: string | null password: 'somepassword',
logo: null,
@Column({ status: null,
type: DataType.BIGINT, biography: null,
allowNull: true website: null,
}) isConfirmed: true,
tempExpirationToken?: number | null temporaryToken: 'temporaryUUIDtoken',
temporaryExpirationToken: new Date(),
@HasMany(() => RefreshToken, { onDelete: 'CASCADE' }) createdAt: new Date(),
refreshTokens!: RefreshToken[] updatedAt: new Date()
@HasMany(() => OAuth, { onDelete: 'CASCADE' })
OAuths!: OAuth[]
@HasMany(() => Member, { onDelete: 'CASCADE' })
members!: Member[]
@HasOne(() => UserSetting, { onDelete: 'CASCADE' })
settings!: UserSetting
toJSON (): UserToJSON {
const attributes = Object.assign({}, this.get())
return deleteObjectAttributes(attributes, userHiddenAttributes) as UserToJSON
}
} }

View File

@ -1,65 +0,0 @@
import {
BelongsTo,
Column,
DataType,
ForeignKey,
Model,
Table
} from 'sequelize-typescript'
import User from './User'
import { deleteObjectAttributes } from '../tools/utils/deleteObjectAttributes'
export const userSettingHiddenAttributes = [
'createdAt',
'updatedAt',
'userId',
'id'
] as const
export type UserSettingHiddenAttributes = typeof userSettingHiddenAttributes[number]
export interface UserSettingToJSON
extends Omit<UserSetting, UserSettingHiddenAttributes> {}
export const languages = ['fr', 'en'] as const
export type Language = typeof languages[number]
export const themes = ['light', 'dark'] as const
export type Theme = typeof themes[number]
@Table
export default class UserSetting extends Model {
@Column({
type: DataType.STRING,
allowNull: false,
defaultValue: 'en'
})
language!: Language
@Column({
type: DataType.STRING,
allowNull: false,
defaultValue: 'dark'
})
theme!: Theme
@Column({
type: DataType.BOOLEAN,
defaultValue: false
})
isPublicEmail!: boolean
@ForeignKey(() => User)
@Column
userId?: number
@BelongsTo(() => User)
user!: User
toJSON (): UserSettingToJSON {
const attributes = Object.assign({}, this.get())
return deleteObjectAttributes(
attributes,
userSettingHiddenAttributes
) as UserSettingToJSON
}
}

View File

@ -0,0 +1,32 @@
import { UserSetting } from '@prisma/client'
import { Type, Static } from '@sinclair/typebox'
import { date, id } from './utils.js'
export const languages = [Type.Literal('fr'), Type.Literal('en')]
export const themes = [Type.Literal('light'), Type.Literal('dark')]
export const userSettingsSchema = {
id,
language: Type.Union(languages, { default: 'en' }),
theme: Type.Union(themes, { default: 'dark' }),
isPublicEmail: Type.Boolean({ default: false }),
isPublicGuilds: Type.Boolean({ default: false }),
createdAt: date.createdAt,
updatedAt: date.updatedAt,
userId: id
}
export type Theme = Static<typeof userSettingsSchema.theme>
export type Language = Static<typeof userSettingsSchema.language>
export const userSettingsExample: UserSetting = {
id: 1,
theme: 'dark',
language: 'en',
isPublicEmail: false,
isPublicGuilds: false,
userId: 1,
createdAt: new Date(),
updatedAt: new Date()
}

View File

@ -1,105 +0,0 @@
-- All users have the password `test`
INSERT INTO `Users` (`id`, `name`, `email`, `password`, `status`, `biography`, `logo`, `isConfirmed`, `tempToken`, `tempExpirationToken`, `createdAt`, `updatedAt`) VALUES
(1, 'Divlo', 'contact@divlo.fr', '$2a$12$rdXfja1jtd88bgvKs4Pbl.yBBFJZP5Y0TcmqOCPm8Fy3BmQCnJHG2', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:47:36', '2021-03-04 12:48:30'),
(2, 'Divlo2', 'divlogaming@gmail.com', '$2a$12$/aIvPyRbp/WUXN1FHwo0w.pBtT1dNls01L8SClpDXbBccjWD33trm', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:47:53', '2021-03-04 12:48:32'),
(3, 'John Doe', 'johndoe@gmail.com', '$2a$12$3Qif9pviwoLLtTAQZqir7u4stLNU6E053EvDeso16aqvuahi7w1se', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:48:24', '2021-03-04 12:48:35'),
(4, 'User', 'user@example.com', '$2a$12$SdgnEhy22aNQXwBRNDy/XeUNWLvu/MneA1Xfs2dtNhai.m/gP9xNi', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:49:58', '2021-03-04 12:50:04');
INSERT INTO `UserSettings` (`id`, `language`, `theme`, `isPublicEmail`, `userId`, `createdAt`, `updatedAt`) VALUES
(1, 'en', 'dark', 0, 1, '2021-03-04 12:47:36', '2021-03-04 12:47:36'),
(2, 'fr', 'dark', 0, 2, '2021-03-04 12:47:53', '2021-03-04 12:47:53'),
(3, 'en', 'light', 0, 3, '2021-03-04 12:48:24', '2021-03-04 12:48:24'),
(4, 'fr', 'light', 0, 4, '2021-03-04 12:49:58', '2021-03-04 12:49:58');
INSERT INTO `Guilds` (`id`, `name`, `description`, `icon`, `isPublic`, `createdAt`, `updatedAt`) VALUES
(1, 'Ligue.dev', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:51:27', '2021-03-04 12:51:27'),
(2, 'Docstring', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:51:39', '2021-03-04 12:51:39'),
(3, 'Read The Docs', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:51:50', '2021-03-04 12:51:50'),
(4, 'Les Joies du Code', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:09', '2021-03-04 12:52:09'),
(5, 'Firecamp', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:19', '2021-03-04 12:52:19'),
(6, 'CodinGame', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:30', '2021-03-04 12:52:30'),
(7, 'Leon AI', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:38', '2021-03-04 12:52:38'),
(8, 'Academind', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:45', '2021-03-04 12:52:45'),
(9, 'StandardJS', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:57', '2021-03-04 12:52:57'),
(10, 'Next.js', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:53:08', '2021-03-04 12:53:08'),
(11, 'Tailwind CSS', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:54:58', '2021-03-04 12:54:58'),
(12, 'Vue Land', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:04', '2021-03-04 12:55:04'),
(13, 'Nuxt.js', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:11', '2021-03-04 12:55:11'),
(14, 'Reactiflux', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:16', '2021-03-04 12:55:16'),
(15, 'Deno', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:25', '2021-03-04 12:55:25'),
(16, 'fastify', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:33', '2021-03-04 12:55:33'),
(17, 'MandarineTS', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:48', '2021-03-04 12:55:48'),
(18, 'Olivia', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:56', '2021-03-04 12:55:56'),
(19, 'yarnpkg', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:19', '2021-03-04 12:56:19'),
(20, 'Qovery', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:25', '2021-03-04 12:56:25'),
(21, 'The Design Collective', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:46', '2021-03-04 12:56:46'),
(22, 'Tauri Apps', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:52', '2021-03-04 12:56:52'),
(23, 'microsoft-python', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:06', '2021-03-04 12:57:06'),
(24, 'AppBrewery', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:17', '2021-03-04 12:57:17'),
(25, 'OpenSauced', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:23', '2021-03-04 12:57:23'),
(26, 'Devsters', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:39', '2021-03-04 12:57:39'),
(27, 'Coding Roads', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:49', '2021-03-04 12:57:49');
INSERT INTO `Channels` (`id`, `name`, `type`, `description`, `isDefault`, `guildId`, `createdAt`, `updatedAt`) VALUES
(1, 'general', 'text', '', 1, 1, '2021-03-04 12:51:27', '2021-03-04 12:51:27'),
(2, 'general', 'text', '', 1, 2, '2021-03-04 12:51:39', '2021-03-04 12:51:39'),
(3, 'general', 'text', '', 1, 3, '2021-03-04 12:51:50', '2021-03-04 12:51:50'),
(4, 'general', 'text', '', 1, 4, '2021-03-04 12:52:09', '2021-03-04 12:52:09'),
(5, 'general', 'text', '', 1, 5, '2021-03-04 12:52:19', '2021-03-04 12:52:19'),
(6, 'general', 'text', '', 1, 6, '2021-03-04 12:52:30', '2021-03-04 12:52:30'),
(7, 'general', 'text', '', 1, 7, '2021-03-04 12:52:38', '2021-03-04 12:52:38'),
(8, 'general', 'text', '', 1, 8, '2021-03-04 12:52:45', '2021-03-04 12:52:45'),
(9, 'general', 'text', '', 1, 9, '2021-03-04 12:52:57', '2021-03-04 12:52:57'),
(10, 'general', 'text', '', 1, 10, '2021-03-04 12:53:08', '2021-03-04 12:53:08'),
(11, 'general', 'text', '', 1, 11, '2021-03-04 12:54:58', '2021-03-04 12:54:58'),
(12, 'general', 'text', '', 1, 12, '2021-03-04 12:55:04', '2021-03-04 12:55:04'),
(13, 'general', 'text', '', 1, 13, '2021-03-04 12:55:11', '2021-03-04 12:55:11'),
(14, 'general', 'text', '', 1, 14, '2021-03-04 12:55:16', '2021-03-04 12:55:16'),
(15, 'general', 'text', '', 1, 15, '2021-03-04 12:55:26', '2021-03-04 12:55:26'),
(16, 'general', 'text', '', 1, 16, '2021-03-04 12:55:33', '2021-03-04 12:55:33'),
(17, 'general', 'text', '', 1, 17, '2021-03-04 12:55:48', '2021-03-04 12:55:48'),
(18, 'general', 'text', '', 1, 18, '2021-03-04 12:55:56', '2021-03-04 12:55:56'),
(19, 'general', 'text', '', 1, 19, '2021-03-04 12:56:19', '2021-03-04 12:56:19'),
(20, 'general', 'text', '', 1, 20, '2021-03-04 12:56:25', '2021-03-04 12:56:25'),
(21, 'general', 'text', '', 1, 21, '2021-03-04 12:56:46', '2021-03-04 12:56:46'),
(22, 'general', 'text', '', 1, 22, '2021-03-04 12:56:52', '2021-03-04 12:56:52'),
(23, 'general', 'text', '', 1, 23, '2021-03-04 12:57:06', '2021-03-04 12:57:06'),
(24, 'general', 'text', '', 1, 24, '2021-03-04 12:57:17', '2021-03-04 12:57:17'),
(25, 'general', 'text', '', 1, 25, '2021-03-04 12:57:23', '2021-03-04 12:57:23'),
(26, 'general', 'text', '', 1, 26, '2021-03-04 12:57:39', '2021-03-04 12:57:39'),
(27, 'general', 'text', '', 1, 27, '2021-03-04 12:57:49', '2021-03-04 12:57:49');
INSERT INTO `Invitations` (`id`, `value`, `expiresIn`, `isPublic`, `guildId`, `createdAt`, `updatedAt`) VALUES
(1, 'firstinvitation', 0, 1, 1, '2021-03-04 13:09:06', '2021-03-04 13:09:06');
INSERT INTO `Members` (`id`, `isOwner`, `lastVisitedChannelId`, `userId`, `guildId`, `createdAt`, `updatedAt`) VALUES
(1, 1, 1, 1, 1, '2021-03-04 12:51:27', '2021-03-04 12:51:27'),
(2, 1, 2, 1, 2, '2021-03-04 12:51:39', '2021-03-04 12:51:39'),
(3, 1, 3, 1, 3, '2021-03-04 12:51:50', '2021-03-04 12:51:50'),
(4, 1, 4, 1, 4, '2021-03-04 12:52:09', '2021-03-04 12:52:09'),
(5, 1, 5, 1, 5, '2021-03-04 12:52:19', '2021-03-04 12:52:19'),
(6, 1, 6, 1, 6, '2021-03-04 12:52:30', '2021-03-04 12:52:30'),
(7, 1, 7, 1, 7, '2021-03-04 12:52:38', '2021-03-04 12:52:38'),
(8, 1, 8, 1, 8, '2021-03-04 12:52:45', '2021-03-04 12:52:45'),
(9, 1, 9, 1, 9, '2021-03-04 12:52:57', '2021-03-04 12:52:57'),
(10, 1, 10, 1, 10, '2021-03-04 12:53:08', '2021-03-04 12:53:08'),
(11, 1, 11, 1, 11, '2021-03-04 12:54:58', '2021-03-04 12:54:58'),
(12, 1, 12, 1, 12, '2021-03-04 12:55:04', '2021-03-04 12:55:04'),
(13, 1, 13, 1, 13, '2021-03-04 12:55:11', '2021-03-04 12:55:11'),
(14, 1, 14, 1, 14, '2021-03-04 12:55:16', '2021-03-04 12:55:16'),
(15, 1, 15, 1, 15, '2021-03-04 12:55:26', '2021-03-04 12:55:26'),
(16, 1, 16, 1, 16, '2021-03-04 12:55:33', '2021-03-04 12:55:33'),
(17, 1, 17, 1, 17, '2021-03-04 12:55:48', '2021-03-04 12:55:48'),
(18, 1, 18, 1, 18, '2021-03-04 12:55:56', '2021-03-04 12:55:56'),
(19, 1, 19, 1, 19, '2021-03-04 12:56:19', '2021-03-04 12:56:19'),
(20, 1, 20, 1, 20, '2021-03-04 12:56:25', '2021-03-04 12:56:25'),
(21, 1, 21, 1, 21, '2021-03-04 12:56:46', '2021-03-04 12:56:46'),
(22, 1, 22, 1, 22, '2021-03-04 12:56:52', '2021-03-04 12:56:52'),
(23, 1, 23, 1, 23, '2021-03-04 12:57:06', '2021-03-04 12:57:06'),
(24, 1, 24, 1, 24, '2021-03-04 12:57:17', '2021-03-04 12:57:17'),
(25, 1, 25, 1, 25, '2021-03-04 12:57:23', '2021-03-04 12:57:23'),
(26, 1, 26, 1, 26, '2021-03-04 12:57:39', '2021-03-04 12:57:39'),
(27, 1, 27, 1, 27, '2021-03-04 12:57:49', '2021-03-04 12:57:49');
INSERT INTO `Messages` (`id`, `value`, `type`, `mimetype`, `memberId`, `channelId`, `createdAt`, `updatedAt`) VALUES
(1, 'Hello world!', 'text', 'text/plain', 1, 1, '2021-03-04 13:08:22', '2021-03-04 13:08:22');

49
src/models/utils.ts Normal file
View File

@ -0,0 +1,49 @@
import { Type } from '@sinclair/typebox'
export const date = {
createdAt: Type.String({
format: 'date-time',
description: 'Created date time'
}),
updatedAt: Type.String({
format: 'date-time',
description: 'Last updated date time'
})
}
export const id = Type.Integer({ minimum: 1, description: 'Unique identifier' })
export const redirectURI = Type.String({ format: 'uri-reference' })
export const fastifyErrors = {
400: Type.Object({
statusCode: Type.Literal(400),
error: Type.Literal('Bad Request'),
message: Type.String()
}),
401: Type.Object({
statusCode: Type.Literal(401),
error: Type.Literal('Unauthorized'),
message: Type.Literal('Unauthorized')
}),
403: Type.Object({
statusCode: Type.Literal(403),
error: Type.Literal('Forbidden'),
message: Type.Literal('Forbidden')
}),
404: Type.Object({
statusCode: Type.Literal(404),
error: Type.Literal('Not Found'),
message: Type.Literal('Not Found')
}),
431: Type.Object({
statusCode: Type.Literal(431),
error: Type.Literal('Request Header Fields Too Large'),
message: Type.String()
}),
500: {
statusCode: Type.Literal(500),
error: Type.Literal('Internal Server Error'),
message: Type.Literal('Something went wrong')
}
}

View File

@ -1,6 +0,0 @@
components:
securitySchemes:
bearerAuth:
type: 'http'
scheme: 'bearer'
bearerFormat: 'JWT'

View File

@ -1,87 +0,0 @@
definitions:
BadRequestError:
'400':
description: 'Bad Request'
content:
application/json:
schema:
type: 'object'
properties:
errors:
type: 'array'
items:
type: 'object'
properties:
message:
type: 'string'
field:
type: 'string'
required:
- 'message'
UnauthorizedError:
'401':
description: 'Unauthorized: Token is missing or invalid Bearer'
content:
application/json:
schema:
type: 'object'
properties:
errors:
type: 'array'
items:
type: 'object'
properties:
message:
type: 'string'
enum: ['Unauthorized: Token is missing or invalid Bearer']
ForbiddenError:
'403':
description: 'Forbidden'
content:
application/json:
schema:
type: 'object'
properties:
errors:
type: 'array'
items:
type: 'object'
properties:
message:
type: 'string'
enum: ['Forbidden']
NotFoundError:
'404':
description: 'Not Found'
content:
application/json:
schema:
type: 'object'
properties:
errors:
type: 'array'
items:
type: 'object'
properties:
message:
type: 'string'
enum: ['Not Found']
PayloadTooLargeError:
'413':
description: 'Payload Too Large'
content:
application/json:
schema:
type: 'object'
properties:
errors:
type: 'array'
items:
type: 'object'
properties:
message:
type: 'string'

View File

@ -1,20 +0,0 @@
definitions:
PaginateModel:
type: 'object'
properties:
hasMore:
type: 'boolean'
totalItems:
type: 'number'
itemsPerPage:
type: 'number'
page:
type: 'number'
PaginateModelParameters:
'parameters':
- name: 'itemsPerPage'
in: 'query'
required: false
- name: 'page'
in: 'query'
required: false

View File

@ -1,25 +0,0 @@
/channels/{channelId}:
delete:
security:
- bearerAuth: []
tags:
- 'channels'
summary: 'DELETE a channel with its id'
parameters:
- name: 'channelId'
in: 'path'
required: true
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
deletedChannelId:
type: 'number'

View File

@ -1,41 +0,0 @@
/channels/{channelId}:
put:
security:
- bearerAuth: []
tags:
- 'channels'
summary: 'UPDATE a channel with its id'
parameters:
- name: 'channelId'
in: 'path'
required: true
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
name:
type: 'string'
minLength: 3
maxLength: 30
description:
type: 'string'
maxLength: 160
isDefault:
type: 'boolean'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
channel:
allOf:
- $ref: '#/definitions/Channel'

View File

@ -1,71 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
import Channel from '../../../../models/Channel'
import { errorsMessages } from '../delete'
import { createChannels } from '../../__test__/utils/createChannel'
describe('DELETE /channels/:channelId', () => {
it('succeeds and delete the channel', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToDelete = result.channels[0]
const response = await request(application)
.delete(`/channels/${channelToDelete.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send()
.expect(200)
expect(response.body.deletedChannelId).toEqual(channelToDelete.id)
const foundChannel = await Channel.findOne({
where: { id: channelToDelete.id }
})
expect(foundChannel).toBeNull()
})
it("fails if the channel doesn't exist", async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.delete('/channels/23')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it('fails if the user is not the owner', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToDelete = result.channels[0]
const userToken = await authenticateUserTest()
const response = await request(application)
.delete(`/channels/${channelToDelete.id as number}`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it("fails if it's the default channel", async () => {
const result = await createChannels([])
const defaultChannel = await Channel.findOne({
where: { guildId: result.guild.id as number, isDefault: true }
})
expect(defaultChannel).not.toBeNull()
const response = await request(application)
.delete(`/channels/${defaultChannel?.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send()
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.channel.shouldNotBeTheDefault])
)
})
})

View File

@ -1,120 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
import Channel from '../../../../models/Channel'
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
import { randomString } from '../../../../tools/utils/random'
import { createChannels } from '../../__test__/utils/createChannel'
describe('PUT /channels/:channelId', () => {
it('succeeds and edit name/description of the channel', async () => {
const name = 'general-updated'
const description = 'general-description'
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToEdit = result.channels[0]
const response = await request(application)
.put(`/channels/${channelToEdit.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name, description })
.expect(200)
expect(response.body.channel.name).toEqual(name)
expect(response.body.channel.description).toEqual(description)
})
it('succeeds and set default channel to true', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToEdit = result.channels[0]
const response = await request(application)
.put(`/channels/${channelToEdit.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ isDefault: true })
.expect(200)
const defaultChannels = await Channel.findAll({
where: { guildId: result.guild.id as number, isDefault: true }
})
expect(defaultChannels.length).toEqual(1)
expect(response.body.channel.name).toEqual(channel1.name)
expect(response.body.channel.isDefault).toBeTruthy()
})
it('succeeds with invalid slug name', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToEdit = result.channels[0]
const name = 'random channel'
const response = await request(application)
.put(`/channels/${channelToEdit.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name })
.expect(200)
expect(response.body.channel.name).toEqual(name)
expect(response.body.channel.isDefault).toBeFalsy()
})
it('fails with too long description', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToEdit = result.channels[0]
const response = await request(application)
.put(`/channels/${channelToEdit.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ description: randomString(170) })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([
commonErrorsMessages.charactersLength('description', { max: 160 })
])
)
})
it('fails with too long name', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToEdit = result.channels[0]
const response = await request(application)
.put(`/channels/${channelToEdit.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name: ' random channel name ' + randomString(35) })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
])
)
})
it("fails if the channel doesn't exist", async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/channels/23')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it('fails if the user is not the owner', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const result = await createChannels([channel1])
const channelToRemove = result.channels[0]
const userToken = await authenticateUserTest()
const response = await request(application)
.put(`/channels/${channelToRemove.id as number}`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
})

View File

@ -1,56 +0,0 @@
import { Request, Response, Router } from 'express'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import Channel from '../../../models/Channel'
import Member from '../../../models/Member'
import { BadRequestError } from '../../../tools/errors/BadRequestError'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../tools/errors/NotFoundError'
import { deleteMessages } from '../../../tools/utils/deleteFiles'
import Message from '../../../models/Message'
import { emitToMembers } from '../../../tools/socket/emitEvents'
export const errorsMessages = {
channel: {
shouldNotBeTheDefault: 'The channel to delete should not be the default'
}
}
export const deleteByIdChannelsRouter = Router()
deleteByIdChannelsRouter.delete(
'/channels/:channelId',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { channelId } = req.params as { channelId: string }
const channel = await Channel.findOne({
where: { id: channelId },
include: [Message]
})
if (channel == null) {
throw new NotFoundError()
}
const member = await Member.findOne({
where: { userId: user.id, guildId: channel.guildId, isOwner: true }
})
if (member == null) {
throw new NotFoundError()
}
if (channel.isDefault) {
throw new BadRequestError(errorsMessages.channel.shouldNotBeTheDefault)
}
const deletedChannelId = channel.id
await deleteMessages(channel.messages)
await channel.destroy()
await emitToMembers({
event: 'channels',
guildId: channel.guildId,
payload: { action: 'delete', item: channel }
})
return res.status(200).json({ deletedChannelId })
}
)

View File

@ -1,33 +0,0 @@
/channels/{channelId}/messages:
get:
security:
- bearerAuth: []
tags:
- 'messages'
summary: 'GET all the messages of a channel'
parameters:
- name: 'channelId'
in: 'path'
required: true
allOf:
- $ref: '#/definitions/PaginateModelParameters'
responses:
allOf:
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/PaginateModel'
type: 'object'
properties:
rows:
type: 'array'
items:
allOf:
- $ref: '#/definitions/Message'
- $ref: '#/definitions/User'

View File

@ -1,44 +0,0 @@
/channels/{channelId}/messages:
post:
security:
- bearerAuth: []
tags:
- 'messages'
summary: 'Create a new message'
parameters:
- name: 'channelId'
in: 'path'
required: true
requestBody:
content:
multipart/form-data:
schema:
type: 'object'
properties:
value:
type: 'string'
minLength: 1
maxLength: 50_000
type:
allOf:
- $ref: '#/definitions/MessageType'
file:
type: 'string'
format: 'binary'
responses:
allOf:
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/NotFoundError'
- $ref: '#/definitions/PayloadTooLargeError'
- '201':
content:
application/json:
schema:
type: 'object'
properties:
message:
allOf:
- $ref: '#/definitions/Message'
- $ref: '#/definitions/User'

View File

@ -1,23 +0,0 @@
import request from 'supertest'
import application from '../../../../../application'
import { createMessages } from '../../../../messages/__test__/utils/createMessages'
describe('GET /channels/:channelId/messages', () => {
it('should get all the messages of the channel', async () => {
const messages = ['Hello world!', 'some random message']
const result = await createMessages(messages)
const response = await request(application)
.get(`/channels/${result.channelId}/messages`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send()
.expect(200)
expect(response.body.hasMore).toBeFalsy()
expect(response.body.totalItems).toEqual(messages.length)
expect(response.body.rows[0].value).toEqual(messages[0])
expect(response.body.rows[1].value).toEqual(messages[1])
expect(response.body.rows[1].user).not.toBeNull()
expect(response.body.rows[1].user.id).toEqual(result.user.id)
expect(response.body.rows[1].user.password).not.toBeDefined()
})
})

View File

@ -1,69 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
import application from '../../../../../application'
import { createChannels } from '../../../__test__/utils/createChannel'
const channel1 = { name: 'general1', description: 'testing' }
describe('POST /channels/:channelId/messages', () => {
it('succeeds and create the message', async () => {
const value = 'my awesome message'
const result = await createChannels([channel1])
expect(result.channels.length).toEqual(1)
const channel = result.channels[0]
const response = await request(application)
.post(`/channels/${channel.id as number}/messages`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value, type: 'text' })
.expect(201)
expect(response.body.message).not.toBeNull()
expect(response.body.message.value).toEqual(value)
expect(response.body.message.type).toEqual('text')
expect(response.body.message.user).not.toBeNull()
expect(response.body.message.user.id).toEqual(result.user.id)
})
it('fails with empty message', async () => {
const result = await createChannels([channel1])
expect(result.channels.length).toEqual(1)
const channel = result.channels[0]
const response1 = await request(application)
.post(`/channels/${channel.id as number}/messages`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ type: 'text' })
.expect(400)
const response2 = await request(application)
.post(`/channels/${channel.id as number}/messages`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ type: 'file' })
.expect(400)
expect(response1.body.errors.length).toEqual(1)
expect(response2.body.errors.length).toEqual(1)
})
it("fails if the channel doesn't exist", async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.post('/channels/2/messages')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ type: 'text', value: 'awesome' })
.expect(404)
expect(response.body.errors.length).toEqual(1)
})
it('fails if the user is not in the guild with this channel', async () => {
const result = await createChannels([channel1])
const channel = result.channels[0]
const userToken = await authenticateUserTest()
const response = await request(application)
.post(`/channels/${channel.id as number}/messages`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ value: 'some random message', type: 'text' })
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
})

View File

@ -1,60 +0,0 @@
import { Request, Response, Router } from 'express'
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
import Channel from '../../../../models/Channel'
import Member from '../../../../models/Member'
import Message from '../../../../models/Message'
import { paginateModel } from '../../../../tools/database/paginateModel'
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
import User from '../../../../models/User'
export const getMessagesRouter = Router()
getMessagesRouter.get(
'/channels/:channelId/messages',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const { itemsPerPage, page } = req.query as {
itemsPerPage: string
page: string
}
const { channelId } = req.params as { channelId: string }
const user = req.user.current
const channel = await Channel.findOne({ where: { id: channelId } })
if (channel == null) {
throw new NotFoundError()
}
const member = await Member.findOne({
where: { userId: user.id, guildId: channel.guildId }
})
if (member == null) {
throw new NotFoundError()
}
member.lastVisitedChannelId = channel.id
await member.save()
const result = await paginateModel({
Model: Message,
queryOptions: { itemsPerPage, page },
findOptions: {
order: [['createdAt', 'DESC']],
include: [{ model: Member, include: [User] }],
where: {
channelId: channel.id
}
}
})
return res.status(200).json({
hasMore: result.hasMore,
totalItems: result.totalItems,
itemsPerPage: result.itemsPerPage,
page: result.page,
rows: result.rows.reverse().map((row: any) => {
return { ...row.toJSON(), user: row.member.user.toJSON() }
})
})
}
)

View File

@ -1,9 +0,0 @@
import { Router } from 'express'
import { postMessagesRouter } from './post'
import { getMessagesRouter } from './get'
export const messagesChannelsRouter = Router()
messagesChannelsRouter.use('/', postMessagesRouter)
messagesChannelsRouter.use('/', getMessagesRouter)

View File

@ -1,122 +0,0 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import fileUpload from 'express-fileupload'
import { v4 as uuidv4 } from 'uuid'
import path from 'path'
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../../tools/middlewares/validateRequest'
import Channel from '../../../../models/Channel'
import Member from '../../../../models/Member'
import Message, { MessageType, messageTypes } from '../../../../models/Message'
import {
commonErrorsMessages,
fileUploadOptions,
messagesFilePath,
tempPath
} from '../../../../tools/configurations/constants'
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
import { onlyPossibleValuesValidation } from '../../../../tools/validations/onlyPossibleValuesValidation'
import { deleteAllFilesInDirectory } from '../../../../tools/utils/deleteFiles'
import { PayloadTooLargeError } from '../../../../tools/errors/PayloadTooLargeError'
import { BadRequestError } from '../../../../tools/errors/BadRequestError'
import { emitToMembers } from '../../../../tools/socket/emitEvents'
export const errorsMessages = {
type: {
shouldNotBeEmpty: 'Type should not be empty'
}
}
export const postMessagesRouter = Router()
postMessagesRouter.post(
'/channels/:channelId/messages',
authenticateUser,
fileUpload(fileUploadOptions),
[
body('value')
.optional({ nullable: true })
.trim()
.escape()
.isLength({ min: 1, max: 50_000 })
.withMessage(
commonErrorsMessages.charactersLength('value', { min: 1, max: 50_000 })
),
body('type')
.notEmpty()
.withMessage(errorsMessages.type.shouldNotBeEmpty)
.trim()
.isString()
.custom(async (type: MessageType) => {
return await onlyPossibleValuesValidation(messageTypes, 'type', type)
})
],
validateRequest,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { value, type } = req.body as {
value?: string
type: MessageType
}
const file = req.files?.file
const { channelId } = req.params as { channelId: string }
const channel = await Channel.findOne({
where: { id: channelId, type: 'text' }
})
if (channel == null) {
throw new NotFoundError()
}
const member = await Member.findOne({
where: { userId: user.id, guildId: channel.guildId }
})
if (member == null) {
throw new NotFoundError()
}
if (
(type === 'file' && file == null) ||
(type === 'text' && value == null)
) {
throw new BadRequestError("You can't send an empty message")
}
let filename: string | null = null
let mimetype = 'text/plain'
if (
value == null &&
type === 'file' &&
file != null &&
!Array.isArray(file)
) {
if (file.truncated) {
await deleteAllFilesInDirectory(tempPath)
throw new PayloadTooLargeError(
commonErrorsMessages.tooLargeFile('file')
)
}
mimetype = file.mimetype
const splitedMimetype = mimetype.split('/')
const fileExtension = splitedMimetype[1]
filename = `${uuidv4()}.${fileExtension}`
await file.mv(path.join(messagesFilePath.filePath, filename))
await deleteAllFilesInDirectory(tempPath)
}
const messageCreated = await Message.create({
value: filename != null ? `${messagesFilePath.name}/${filename}` : value,
type,
mimetype,
memberId: member.id,
channelId: channel.id
})
const message = { ...messageCreated.toJSON(), user: req.user.current }
await emitToMembers({
event: 'messages',
guildId: member.guildId,
payload: { action: 'create', item: message }
})
return res.status(201).json({ message })
}
)

View File

@ -1,92 +0,0 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import Channel from '../../../models/Channel'
import Member from '../../../models/Member'
import { commonErrorsMessages } from '../../../tools/configurations/constants'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../tools/errors/NotFoundError'
import { emitToMembers } from '../../../tools/socket/emitEvents'
export const putByIdChannelsRouter = Router()
putByIdChannelsRouter.put(
'/channels/:channelId',
authenticateUser,
[
body('name')
.optional({ nullable: true })
.isString()
.trim()
.escape()
.isLength({ max: 30, min: 3 })
.withMessage(
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
),
body('description')
.optional({ nullable: true })
.trim()
.escape()
.isLength({ max: 160 })
.withMessage(
commonErrorsMessages.charactersLength('description', { max: 160 })
),
body('isDefault').optional({ nullable: true }).isBoolean()
],
validateRequest,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { channelId } = req.params as { channelId: string }
const { name, description, isDefault } = req.body as {
name?: string
description?: string
isDefault?: boolean
}
const channel = await Channel.findOne({
where: { id: channelId }
})
if (channel == null) {
throw new NotFoundError()
}
const member = await Member.findOne({
where: { userId: user.id, guildId: channel.guildId, isOwner: true }
})
if (member == null) {
throw new NotFoundError()
}
channel.name = name ?? channel.name
channel.description = description ?? channel.description
if (isDefault != null) {
const defaultChannel = await Channel.findOne({
where: { isDefault: true, guildId: member.guildId }
})
if (isDefault && defaultChannel != null) {
defaultChannel.isDefault = false
channel.isDefault = true
await defaultChannel.save()
const defaultChannelMembers = await Member.findAll({
where: {
guildId: member.guildId,
lastVisitedChannelId: defaultChannel.id
}
})
for (const defaultChannelMember of defaultChannelMembers) {
defaultChannelMember.lastVisitedChannelId = channel.id
await defaultChannelMember.save()
}
}
}
await channel.save()
await emitToMembers({
event: 'channels',
guildId: channel.guildId,
payload: { action: 'update', item: channel }
})
return res.status(200).json({ channel })
}
)

View File

@ -1,24 +0,0 @@
definitions:
Channel:
type: 'object'
properties:
id:
type: 'integer'
description: 'Unique id'
name:
type: 'string'
type:
type: 'string'
enum: ['text', 'voice']
description:
type: 'string'
isDefault:
type: 'boolean'
guildId:
type: 'integer'
createdAt:
type: 'string'
format: 'date-time'
updatedAt:
type: 'string'
format: 'date-time'

View File

@ -1,42 +0,0 @@
import request from 'supertest'
import application from '../../../../application'
import Channel from '../../../../models/Channel'
import {
createGuild,
CreateGuildResult
} from '../../../guilds/__test__/utils/createGuild'
interface ChannelOptions {
name: string
description: string
}
interface CreateChannelsResult extends CreateGuildResult {
channels: Channel[]
}
export const createChannels = async (
channels: ChannelOptions[]
): Promise<CreateChannelsResult> => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const channelsResponses: Channel[] = []
for (const { name, description } of channels) {
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/channels`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name, description })
.expect(201)
channelsResponses.push(response.body.channel)
}
return {
...result,
channels: channelsResponses
}
}

View File

@ -1,11 +0,0 @@
import { Router } from 'express'
import { deleteByIdChannelsRouter } from './[channelId]/delete'
import { messagesChannelsRouter } from './[channelId]/messages'
import { putByIdChannelsRouter } from './[channelId]/put'
export const channelsRouter = Router()
channelsRouter.use('/', deleteByIdChannelsRouter)
channelsRouter.use('/', putByIdChannelsRouter)
channelsRouter.use('/', messagesChannelsRouter)

View File

@ -1,8 +0,0 @@
import { Router } from 'express'
import swaggerUi from 'swagger-ui-express'
import { swaggerSpecification } from '../../tools/configurations/swaggerSpecification'
export const documentationRouter = Router()
documentationRouter.use('/documentation', swaggerUi.serve, swaggerUi.setup(swaggerSpecification))

View File

@ -1,24 +0,0 @@
/guilds/{guildId}:
delete:
security:
- bearerAuth: []
tags:
- 'guilds'
summary: 'DELETE a guild with its id'
parameters:
- name: 'guildId'
in: 'path'
required: true
responses:
allOf:
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
deletedGuildId:
type: 'number'

View File

@ -1,25 +0,0 @@
/guilds/{guildId}:
get:
security:
- bearerAuth: []
tags:
- 'guilds'
summary: 'GET a guild with its id'
parameters:
- name: 'guildId'
in: 'path'
required: true
responses:
allOf:
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
guild:
allOf:
- $ref: '#/definitions/Guild'

View File

@ -1,48 +0,0 @@
/guilds/{guildId}:
put:
security:
- bearerAuth: []
tags:
- 'guilds'
summary: 'Update a guild with its id'
parameters:
- name: 'guildId'
in: 'path'
required: true
requestBody:
content:
multipart/form-data:
schema:
type: 'object'
properties:
name:
type: 'string'
minLength: 3
maxLength: 30
description:
type: 'string'
maxLength: 160
icon:
type: 'string'
format: 'binary'
isPublic:
type: 'boolean'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
guild:
allOf:
- $ref: '#/definitions/Guild'
type: 'object'
properties:
publicInvitation:
type: 'string'

View File

@ -1,62 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
import Guild from '../../../../models/Guild'
import { createGuild } from '../../__test__/utils/createGuild'
describe('DELETE /guilds/:guildId', () => {
it('succeeds and delete the guild', async () => {
const name = 'guild'
const description = 'testing'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.delete(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send()
.expect(200)
expect(response.body.deletedGuildId).toEqual(result.guild.id)
const foundGuild = await Guild.findOne({ where: { id: result?.guild.id as number } })
expect(foundGuild).toBeNull()
})
it("fails if the guild doesn't exist", async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.delete('/guilds/23')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it("fails if the user isn't the owner", async () => {
const name = 'guild'
const description = 'testing'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const userToken = await authenticateUserTest()
const response = await request(application)
.delete(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
})

View File

@ -1,58 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
import { createGuild } from '../../__test__/utils/createGuild'
describe('GET /guilds/:guildId', () => {
it('succeeds and get the guild', async () => {
const name = 'guild'
const description = 'testing'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.get(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send()
.expect(200)
expect(response.body.guild.name).toEqual(name)
expect(response.body.guild.description).toEqual(description)
})
it("fails if the user isn't a member", async () => {
const result = await createGuild({
guild: { description: 'testing', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const userToken = await authenticateUserTest()
const response = await request(application)
.get(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it("fails if the guild doesn't exist", async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.get('/guilds/23')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
})

View File

@ -1,182 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
import Guild from '../../../../models/Guild'
import Invitation from '../../../../models/Invitation'
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
import { randomString } from '../../../../tools/utils/random'
import { createGuild } from '../../__test__/utils/createGuild'
describe('PUT /guilds/:guildId', () => {
it('succeeds and edit the guild', async () => {
const name = 'guild'
const newName = 'guildtest'
const description = 'testing'
const newDescription = 'new description'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.put(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name: newName, description: newDescription })
.expect(200)
expect(response.body.guild.name).toEqual(newName)
expect(response.body.guild.description).toEqual(newDescription)
expect(response.body.guild.publicInvitation).toBeNull()
const foundGuild = await Guild.findOne({
where: { id: result?.guild.id as number }
})
expect(foundGuild?.name).toEqual(newName)
expect(foundGuild?.description).toEqual(newDescription)
})
it('succeeds and create/delete public invitations', async () => {
const name = 'guild'
const description = 'testing'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const resIsPublic = await request(application)
.put(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ isPublic: true })
.expect(200)
expect(resIsPublic.body.guild.isPublic).toBeTruthy()
expect(typeof resIsPublic.body.guild.publicInvitation).toBe('string')
const publicInvitation = await Invitation.findOne({
where: { isPublic: true, guildId: result?.guild.id as number }
})
expect(publicInvitation).not.toBeNull()
expect(publicInvitation?.expiresIn).toEqual(0)
const resIsNotPublic = await request(application)
.put(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ isPublic: false })
.expect(200)
expect(resIsNotPublic.body.guild.isPublic).toBeFalsy()
expect(resIsNotPublic.body.guild.publicInvitation).toBeNull()
const notPublicInvitation = await Invitation.findOne({
where: { isPublic: false, guildId: result?.guild.id as number }
})
expect(notPublicInvitation).toBeNull()
})
it("fails if the user isn't the owner", async () => {
const name = 'guild'
const newName = 'guildtest'
const description = 'testing'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const userToken = await authenticateUserTest()
const response = await request(application)
.put(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ name: newName })
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it("fails if the guild doesn't exist", async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/guilds/23')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ name: 'kjdjhdjh' })
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it('fails with invalid name', async () => {
const name = 'guild'
const description = 'testing'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.put(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name: randomString(35) })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
])
)
})
it('fails with name already used', async () => {
const { guild } = await createGuild({
guild: { description: 'testing', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const result = await createGuild({
guild: { description: 'testing', name: 'guild2' },
user: {
email: 'test@test2.com',
name: 'Test2'
}
})
const response = await request(application)
.put(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name: guild.name })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Name already used']))
})
it('fails with invalid description', async () => {
const name = 'guild'
const description = 'testing'
const result = await createGuild({
guild: { description, name },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.put(`/guilds/${result.guild.id as number}`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ description: randomString(165) })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([
commonErrorsMessages.charactersLength('description', { max: 160 })
])
)
})
})

View File

@ -1,31 +0,0 @@
/guilds/{guildId}/channels:
get:
security:
- bearerAuth: []
tags:
- 'channels'
summary: 'GET all the channels of a guild'
parameters:
- name: 'guildId'
in: 'path'
required: true
allOf:
- $ref: '#/definitions/PaginateModelParameters'
responses:
allOf:
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/BadRequestError'
- '200':
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/PaginateModel'
type: 'object'
properties:
rows:
type: 'array'
items:
allOf:
- $ref: '#/definitions/Channel'

View File

@ -1,39 +0,0 @@
/guilds/{guildId}/channels:
post:
security:
- bearerAuth: []
tags:
- 'channels'
summary: 'Create a channel'
parameters:
- name: 'guildId'
in: 'path'
required: true
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
name:
type: 'string'
minLength: 3
maxLength: 30
description:
type: 'string'
maxLength: 160
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/NotFoundError'
- '201':
content:
application/json:
schema:
type: 'object'
properties:
channel:
allOf:
- $ref: '#/definitions/Channel'

View File

@ -1,23 +0,0 @@
import request from 'supertest'
import application from '../../../../../application'
import { createChannels } from '../../../../channels/__test__/utils/createChannel'
describe('GET /guilds/:guildId/channels', () => {
it('should get all the channels of the guild', async () => {
const channel1 = { name: 'general1', description: 'testing' }
const channel2 = { name: 'general2', description: 'testing' }
const result = await createChannels([channel1, channel2])
const response = await request(application)
.get(`/guilds/${result.guild.id as number}/channels/`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send()
.expect(200)
expect(response.body.hasMore).toBeFalsy()
expect(response.body.rows.length).toEqual(3)
expect(response.body.rows[0].name).toEqual(channel2.name)
expect(response.body.rows[0].description).toEqual(channel2.description)
expect(response.body.rows[1].name).toEqual(channel1.name)
expect(response.body.rows[1].description).toEqual(channel1.description)
})
})

View File

@ -1,146 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
import application from '../../../../../application'
import { commonErrorsMessages } from '../../../../../tools/configurations/constants'
import { randomString } from '../../../../../tools/utils/random'
import { createGuild } from '../../../__test__/utils/createGuild'
import { errorsMessages } from '../post'
describe('POST /guilds/:guildId/channels', () => {
it('succeeds with valid name/description', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const name = 'channel-name'
const description = 'testing channel creation'
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/channels`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name, description })
.expect(201)
expect(response.body.channel).not.toBeNull()
expect(response.body.channel.guildId).not.toBeUndefined()
expect(response.body.channel.name).toBe(name)
expect(response.body.channel.description).toBe(description)
})
it('succeeds with only channel name', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const name = 'channel-name'
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/channels`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name })
.expect(201)
expect(response.body.channel).not.toBeNull()
expect(response.body.channel.name).toBe(name)
})
it('succeeds with invalid slug name', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const name = 'channel name'
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/channels`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name, description: 'testing' })
.expect(201)
expect(response.body.channel).not.toBeNull()
expect(response.body.channel.name).toBe(name)
})
it('fails without name', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/channels`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ description: 'testing channel creation' })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(3)
expect(errors).toEqual(
expect.arrayContaining([
errorsMessages.name.isRequired,
commonErrorsMessages.charactersLength('name', { min: 3, max: 30 })
])
)
})
it('fails with invalid description', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/channels`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ name: 'channel-name', description: randomString(170) })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([
commonErrorsMessages.charactersLength('description', { max: 160 })
])
)
})
it("fails if the user isn't the owner", async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const userToken = await authenticateUserTest()
const name = 'channel-name'
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/channels`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ name, description: 'testing channel creation' })
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
it("fails if the guild does't exist", async () => {
const userToken = await authenticateUserTest()
const name = 'channel-name'
const response = await request(application)
.post('/guilds/1/channels')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ name, description: 'testing channel creation' })
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
})

View File

@ -1,43 +0,0 @@
import { Request, Response, Router } from 'express'
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
import Channel from '../../../../models/Channel'
import Member from '../../../../models/Member'
import { paginateModel } from '../../../../tools/database/paginateModel'
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
export const getChannelsRouter = Router()
getChannelsRouter.get(
'/guilds/:guildId/channels',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const { itemsPerPage, page } = req.query as {
itemsPerPage: string
page: string
}
const user = req.user.current
const { guildId } = req.params as { guildId: string }
const member = await Member.findOne({
where: { userId: user.id, guildId }
})
if (member == null) {
throw new NotFoundError()
}
const channels = await paginateModel({
Model: Channel,
queryOptions: { itemsPerPage, page },
findOptions: {
order: [['createdAt', 'DESC']],
where: {
guildId: member.guildId
}
}
})
return res.status(200).json(channels)
}
)

View File

@ -1,9 +0,0 @@
import { Router } from 'express'
import { getChannelsRouter } from './get'
import { postChannelsRouter } from './post'
export const guildsChannelsRouter = Router()
guildsChannelsRouter.use('/', getChannelsRouter)
guildsChannelsRouter.use('/', postChannelsRouter)

View File

@ -1,73 +0,0 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../../tools/middlewares/validateRequest'
import Channel from '../../../../models/Channel'
import Member from '../../../../models/Member'
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
import { emitToMembers } from '../../../../tools/socket/emitEvents'
export const errorsMessages = {
name: {
isRequired: 'Name is required'
}
}
export const postChannelsRouter = Router()
postChannelsRouter.post(
'/guilds/:guildId/channels',
authenticateUser,
[
body('name')
.notEmpty()
.withMessage(errorsMessages.name.isRequired)
.isString()
.trim()
.escape()
.isLength({ max: 30, min: 3 })
.withMessage(
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
),
body('description')
.optional({ nullable: true })
.trim()
.escape()
.isLength({ max: 160 })
.withMessage(
commonErrorsMessages.charactersLength('description', { max: 160 })
)
],
validateRequest,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { name, description = '' } = req.body as {
name: string
description?: string
}
const { guildId } = req.params as { guildId: string }
const member = await Member.findOne({
where: { userId: user.id, guildId, isOwner: true }
})
if (member == null) {
throw new NotFoundError()
}
const channel = await Channel.create({
name,
description,
guildId: member.guildId
})
await emitToMembers({
event: 'channels',
guildId: member.guildId,
payload: { action: 'create', item: channel }
})
return res.status(201).json({ channel })
}
)

View File

@ -1,57 +0,0 @@
import { Request, Response, Router } from 'express'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import Guild from '../../../models/Guild'
import Member from '../../../models/Member'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../tools/errors/NotFoundError'
import { guildsIconPath } from '../../../tools/configurations/constants'
import { deleteFile, deleteMessages } from '../../../tools/utils/deleteFiles'
import Channel from '../../../models/Channel'
import Message from '../../../models/Message'
import { emitToMembers } from '../../../tools/socket/emitEvents'
export const deleteByIdGuildsRouter = Router()
deleteByIdGuildsRouter.delete(
'/guilds/:guildId',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { guildId } = req.params as { guildId: string }
const member = await Member.findOne({
where: { userId: user.id, guildId, isOwner: true },
include: [Guild]
})
if (member == null) {
throw new NotFoundError()
}
const deletedGuildId = member.guild.id
await emitToMembers({
event: 'guilds',
guildId: member.guildId,
payload: { action: 'delete', item: member.guild }
})
await deleteFile({
basePath: guildsIconPath,
valueSavedInDatabase: member.guild.icon
})
const members = await Member.findAll({ where: { guildId: deletedGuildId } })
for (const member of members) {
await member.destroy()
}
const channels = await Channel.findAll({
where: { guildId },
include: [Message]
})
for (const channel of channels) {
await deleteMessages(channel.messages)
await channel.destroy()
}
await member.guild.destroy()
return res.status(200).json({ deletedGuildId })
}
)

View File

@ -1,29 +0,0 @@
import { Request, Response, Router } from 'express'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import Guild from '../../../models/Guild'
import Member from '../../../models/Member'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../tools/errors/NotFoundError'
export const getByIdGuildsRouter = Router()
getByIdGuildsRouter.get(
'/guilds/:guildId',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { guildId } = req.params as { guildId: string }
const member = await Member.findOne({
where: { userId: user.id, guildId },
include: [Guild]
})
if (member == null) {
throw new NotFoundError()
}
return res.status(200).json({ guild: member.guild })
}
)

View File

@ -1,19 +0,0 @@
import { Router } from 'express'
import { deleteByIdGuildsRouter } from './delete'
import { getByIdGuildsRouter } from './get'
import { putByIdGuildsRouter } from './put'
import { guildsChannelsRouter } from './channels'
import { guildsInvitationsRouter } from './invitations'
import { guildsMembersRouter } from './members'
export const guildsGetByIdRouter = Router()
guildsGetByIdRouter.use('/', getByIdGuildsRouter)
guildsGetByIdRouter.use('/', deleteByIdGuildsRouter)
guildsGetByIdRouter.use('/', putByIdGuildsRouter)
guildsGetByIdRouter.use('/', guildsChannelsRouter)
guildsGetByIdRouter.use('/', guildsInvitationsRouter)
guildsGetByIdRouter.use('/', guildsMembersRouter)

View File

@ -1,31 +0,0 @@
/guilds/{guildId}/invitations:
get:
security:
- bearerAuth: []
tags:
- 'invitations'
summary: 'GET all the invitations of a guild'
parameters:
- name: 'guildId'
in: 'path'
required: true
allOf:
- $ref: '#/definitions/PaginateModelParameters'
responses:
allOf:
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/PaginateModel'
type: 'object'
properties:
rows:
type: 'array'
items:
allOf:
- $ref: '#/definitions/Invitation'

View File

@ -1,40 +0,0 @@
/guilds/{guildId}/invitations:
post:
security:
- bearerAuth: []
tags:
- 'invitations'
summary: 'Create an invitation'
parameters:
- name: 'guildId'
in: 'path'
required: true
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
value:
type: 'string'
minLength: 1
maxLength: 250
expiresIn:
type: 'integer'
isPublic:
type: 'boolean'
responses:
allOf:
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/NotFoundError'
- '201':
content:
application/json:
schema:
type: 'object'
properties:
invitation:
allOf:
- $ref: '#/definitions/Invitation'

View File

@ -1,46 +0,0 @@
import request from 'supertest'
import application from '../../../../../application'
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
import { createInvitation } from '../../../../invitations/__test__/utils/createInvitation'
describe('GET /guilds/:guildId/invitations', () => {
it('should get all the invitations of the guild', async () => {
const value1 = 'awesome'
const value2 = 'awesomevalue'
const result = await createInvitation({ value: value1 })
await createInvitation({
value: value2,
guildId: result?.guild.id
})
const response = await request(application)
.get(`/guilds/${result?.guild.id as number}/invitations`)
.set(
'Authorization',
`${result?.user.type as string} ${result?.user.accessToken as string}`
)
.send()
.expect(200)
expect(response.body.hasMore).toBeFalsy()
expect(response.body.rows.length).toEqual(2)
expect(response.body.rows[0].value).toEqual(value2)
expect(response.body.rows[1].value).toEqual(value1)
})
it('fails if the user is not the owner', async () => {
const userToken = await authenticateUserTest()
const result = await createInvitation()
const response = await request(application)
.get(`/guilds/${result?.guild.id as number}/invitations`)
.set(
'Authorization',
`${userToken.type as string} ${userToken.accessToken}`
)
.send()
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
})

View File

@ -1,163 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
import application from '../../../../../application'
import { createGuild } from '../../../__test__/utils/createGuild'
import { errorsMessages } from '../post'
import { commonErrorsMessages } from '../../../../../tools/configurations/constants'
describe('POST /guilds/:guildId/invitations', () => {
it('succeeds and create the invitation', async () => {
const value = 'random'
const expiresIn = 0
const isPublic = false
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value, expiresIn, isPublic })
.expect(201)
expect(response.body.invitation.value).toEqual(value)
expect(response.body.invitation.expiresIn).toEqual(expiresIn)
expect(response.body.invitation.isPublic).toEqual(isPublic)
})
it('fails with empty value', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ expiresIn: 0 })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(3)
expect(errors).toEqual(
expect.arrayContaining([
errorsMessages.value.shouldNotBeEmpty,
errorsMessages.value.mustBeSlug,
commonErrorsMessages.charactersLength('value', { max: 250, min: 1 })
])
)
})
it('fails with invalid slug value', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value: 'random value' })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.value.mustBeSlug])
)
})
it('fails with negative expiresIn', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value: 'awesome', expiresIn: -42 })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.expiresIn.mustBeGreaterOrEqual])
)
})
it('fails if the invitation slug value already exists', async () => {
const value = 'awesome'
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value })
.expect(201)
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Value already used']))
})
it('fails with isPublic: true - if there is already a public invitation for this guild', async () => {
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value: 'awesome', isPublic: true })
.expect(201)
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
.send({ value: 'awesome2', isPublic: true })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.public.alreadyHasInvitation])
)
})
it('fails if the user is not the owner', async () => {
const userToken = await authenticateUserTest()
const result = await createGuild({
guild: { description: 'description', name: 'guild' },
user: {
email: 'test@test.com',
name: 'Test'
}
})
const response = await request(application)
.post(`/guilds/${result.guild.id as number}/invitations`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ value: 'value' })
.expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
})
})

Some files were not shown because too many files have changed in this diff Show More