Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c753d1e7
|
||
|
|
1f7bd51a7b
|
||
|
|
f34fd644b8
|
||
|
|
c827c2e58d
|
||
|
|
30bcfdb436
|
||
|
|
0b4753c47b
|
||
|
|
69b90849fd
|
||
|
|
f2950ecf86
|
||
|
|
1e17308aab
|
||
|
|
ca4b594828
|
||
|
|
2ea16773c8
|
||
|
|
616d7f76d7
|
||
|
|
f882a70343
|
||
|
|
779bb5c112
|
||
|
|
5753477717
|
||
|
|
7615ec670e
|
||
|
|
40cfff683d
|
||
|
|
bb52782226
|
||
|
|
6a70274623
|
||
|
|
aabc615b89
|
||
|
|
f9b202375f
|
||
|
|
6398965f16
|
||
|
|
9e9b1db012
|
||
|
|
62bf03d07a
|
||
|
|
c83ba6eb7d
|
||
|
|
05a05a1940
|
||
|
|
7c065a2fb9
|
||
|
|
001cdaff8f
|
||
|
|
0eb940c5ce
|
||
|
|
f0617c8ba5
|
||
|
|
27ea6fa413
|
||
|
|
e2146f4502
|
||
|
|
484b775923
|
||
|
|
5b05a14932
|
||
|
|
2704f7d5c5
|
||
|
|
d271cc215b
|
||
|
|
9eb5a60fb2
|
||
|
|
950646a426
|
||
|
|
a9b80e66cd
|
||
|
|
307655371d
|
||
|
|
8eb0cba050
|
||
|
|
50787c9357
|
||
|
|
0972ed951f
|
||
|
|
f852835c59
|
||
|
|
2c18fd1c1a
|
||
|
|
6d80795e44
|
||
|
|
ace438be6b
|
||
|
|
ea1afa7688
|
||
|
|
0976850c0c
|
||
|
|
ed3ed66cab
|
||
|
|
46ffdd809c
|
||
|
|
2dcd277347
|
||
|
|
9486803aeb
|
||
|
|
1e0bb03182
|
||
|
|
f1d1359dcb
|
||
|
|
7b76942795
|
||
|
|
1be8571f26
|
||
|
|
29b1db4aed
|
||
|
|
9db3067721
|
||
|
|
27f8c7148a
|
||
|
|
209711195b
|
||
|
|
fafdaee668
|
||
|
|
01117aad6d
|
||
|
|
e73ae80fc5
|
||
|
|
9ccbd2ceb1
|
||
|
|
3edf5cc070
|
||
|
|
2d670ad9cf
|
||
|
|
fc2f5214b1
|
||
|
|
aa17c57e26
|
||
|
|
004021ff84
|
||
|
|
586d827552
|
||
|
|
17fc8d4b68
|
||
|
|
4a66676fcb
|
||
|
|
48db40b3d4
|
||
|
|
c32d4e7203
|
||
|
|
9b7c2c8e5b
|
||
|
|
0584c46190
|
||
|
|
13ccdbc2ab
|
||
| a4d0c6aa8c | |||
|
|
ba0234fd13
|
||
|
|
81461d04e9
|
||
| c4e6be4452 | |||
| 18288cf8f3 | |||
| 3ffc5b6fde | |||
| 5413774cf4 | |||
| e342eacc69 | |||
|
|
60643f6aa8
|
||
|
|
929dd74ec1
|
||
|
|
87534c0596
|
||
|
|
fa673d0f80
|
||
|
|
8df6d15b19
|
||
|
|
0144421f03
|
||
|
|
df9a6c6f36
|
||
|
|
15426a9e18
|
||
|
|
a28844e9b7
|
||
|
|
ae916931f6
|
||
|
|
e4dc5dd10b
|
||
|
|
878c35cbcd
|
||
|
|
8cf0036248
|
||
|
|
c389024f59
|
||
|
|
bbdbe58af5
|
||
|
|
5951e41eb5
|
||
|
|
7442236e8d
|
||
|
|
3ef7292287
|
||
|
|
f1a571196d
|
||
|
|
f4cd20a010
|
||
|
|
988eacc281
|
||
|
|
329a150ff8
|
||
|
|
4372f75025
|
||
|
|
4fa163b542
|
||
|
|
7f0749808e
|
||
|
|
bcbc93d6a3
|
||
|
|
89587d6abc
|
||
|
|
3347d693ce
|
||
|
|
5048b4813c
|
||
|
|
906f615428
|
||
|
|
fc4efd1e24
|
||
|
|
6bc6a8f68c
|
||
|
|
e69156407e
|
||
|
|
7dce7ec286
|
||
|
|
029bbe9bb9
|
||
|
|
c3f57db1e5
|
||
|
|
939448d15c
|
||
|
|
4e61b0de9a
|
||
|
|
73556894f8
|
||
|
|
96a9d6e7a7
|
||
|
|
058830bb60
|
||
|
|
02d612e026
|
||
|
|
498f85d24e
|
||
|
|
10cc5a6d8d
|
||
|
|
7503707ef1
|
||
|
|
8778508ced
|
||
|
|
b968d1e6f8
|
||
|
|
0382b21a65
|
||
|
|
764c4c07c8
|
||
|
|
68b5071f6d
|
||
|
|
f5c90b0ae4
|
||
|
|
c8820a71b6
|
||
|
|
9b714716f6
|
||
|
|
3a5550d6eb
|
||
|
|
07cdb741b3
|
||
|
|
02796e4e1f
|
||
|
|
951b38db67
|
||
|
|
a90aba2748
|
||
|
|
3f0b1e5119
|
||
|
|
aff8acebf8
|
||
|
|
a721b4041c
|
||
|
|
f4a1a2f4df
|
||
|
|
0548c418c7
|
||
|
|
dd0a9e620b
|
||
|
|
7e7b19fe9f
|
||
|
|
57bc51290b
|
||
|
|
d613a89e63
|
||
|
|
67a10ad7d8
|
||
|
|
82e98f4fce
|
||
|
|
70a4249e41
|
||
|
|
de7d41f4a1
|
||
|
|
2da1142866
|
||
|
|
4e8e441d98
|
||
|
|
0e83de70e3
|
||
|
|
8169ef719a
|
||
|
|
7637499a97
|
||
|
|
c03ad8c221
|
||
|
|
8483927823
|
||
|
|
e7b79013fd
|
||
|
|
b6b37ebc6b
|
||
|
|
d647a585c8
|
||
|
|
6a2abf115f
|
||
|
|
ded2d3220d
|
||
|
|
162d53630d
|
||
|
|
0e8a2e3986
|
||
|
|
5cc77ae5b0
|
||
|
|
3b9b73bc4b
|
||
|
|
a6e34c511e
|
||
|
|
13650b6a39
|
||
|
|
dbe90ae47b
|
||
|
|
d0c78cb206
|
||
|
|
1c38434b6e
|
||
|
|
1666aaadf2
|
||
|
|
6ac429f111
|
||
|
|
872087dc44
|
||
|
|
f8eaad3f81
|
||
|
|
5f176def8c
|
||
|
|
9ef6bbfd96
|
||
|
|
61b25f7b9e
|
||
|
|
d0286d51ff
|
||
|
|
2291cc8afb
|
||
|
|
bad2caef08
|
||
|
|
ac4568a0f0
|
||
|
|
a11a332eaa
|
||
|
|
02c00e8aae
|
||
|
|
2886e50a0c
|
||
|
|
59a5cc941e
|
||
|
|
78db4b1c34
|
||
|
|
b177bee75c
|
||
|
|
0cd6509273
|
||
|
|
05a56ff87d
|
||
|
|
3fa11474c1
|
||
|
|
4c12c5c5cb
|
||
|
|
48dbdbfdcc
|
||
|
|
002a6b912a
|
||
|
|
733ffbff31
|
||
|
|
4700526dd2
|
||
|
|
2450977e61
|
||
|
|
afc18b555a
|
||
|
|
9699127739
|
||
|
|
938d8bde7b
|
||
|
|
65c7096f46
|
||
|
|
57c00ad4d1
|
||
|
|
39618f7708
|
||
|
|
e84e4a5a9d
|
||
|
|
e74973a9d0
|
||
|
|
9233c1bf89
|
||
|
|
88c7f45a2c
|
||
|
|
9af72156f5
|
@@ -1,36 +0,0 @@
|
|||||||
name: Backend Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-cache
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
- name: Run Backend Tests
|
|
||||||
run: pnpm -F @memegoat/backend test
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
name: Deploy to Production
|
# Pipeline CI/CD pour Gitea Actions (Forgejo)
|
||||||
|
# Compatible avec GitHub Actions pour la portabilité
|
||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: Validate Build & Lint
|
name: Valider ${{ matrix.component }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -16,23 +18,23 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Installer pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 9
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Configurer Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Obtenir le chemin du store pnpm
|
||||||
id: pnpm-cache
|
id: pnpm-cache
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Configurer le cache pnpm
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
@@ -40,28 +42,45 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Installer les dépendances
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline
|
run: pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
- name: Lint ${{ matrix.component }}
|
- name: Lint ${{ matrix.component }}
|
||||||
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
||||||
|
|
||||||
|
- name: Tester ${{ matrix.component }}
|
||||||
|
if: matrix.component == 'backend' || matrix.component == 'frontend'
|
||||||
|
run: |
|
||||||
|
if pnpm -F @memegoat/${{ matrix.component }} run | grep -q "test"; then
|
||||||
|
pnpm -F @memegoat/${{ matrix.component }} test
|
||||||
|
else
|
||||||
|
echo "Pas de script de test trouvé pour ${{ matrix.component }}, passage."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build ${{ matrix.component }}
|
- name: Build ${{ matrix.component }}
|
||||||
run: pnpm -F @memegoat/${{ matrix.component }} build
|
run: pnpm -F @memegoat/${{ matrix.component }} build
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to Production
|
name: Déploiement en Production
|
||||||
needs: validate
|
needs: validate
|
||||||
|
# Déclenchement uniquement sur push sur main ou tag de version
|
||||||
|
# Gitea supporte le contexte 'github' pour la compatibilité
|
||||||
|
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy with Docker Compose
|
- name: Vérifier l'environnement Docker
|
||||||
run: |
|
run: |
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker version
|
||||||
|
docker compose version
|
||||||
|
|
||||||
|
- name: Déployer avec Docker Compose
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
|
||||||
env:
|
env:
|
||||||
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
|
BACKEND_PORT: ${{ secrets.BACKEND_PORT }}
|
||||||
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
|
FRONTEND_PORT: ${{ secrets.FRONTEND_PORT }}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
name: Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- 'documentation/**'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- 'documentation/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
component: [backend, frontend, documentation]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-cache
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> "${GITEA_OUTPUT:-$GITHUB_OUTPUT}"
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
- name: Lint ${{ matrix.component }}
|
|
||||||
run: pnpm -F @memegoat/${{ matrix.component }} lint
|
|
||||||
225
.output.txt
225
.output.txt
@@ -1,225 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@memegoat/source",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "",
|
|
||||||
"scripts": {
|
|
||||||
"build": "pnpm run build:back && pnpm run build:front && pnpm run build:docs",
|
|
||||||
"build:front": "pnpm run -F @memegoat/frontend build",
|
|
||||||
"build:back": "pnpm run -F @memegoat/backend build",
|
|
||||||
"build:docs": "pnpm run -F @memegoat/documentation build",
|
|
||||||
"lint": "pnpm run lint:back && pnpm run lint:front && pnpm run lint:docs",
|
|
||||||
"lint:back": "pnpm run -F @memegoat/backend lint",
|
|
||||||
"lint:front": "pnpm run -F @memegoat/frontend lint",
|
|
||||||
"lint:docs": "pnpm run -F @memegoat/documentation lint",
|
|
||||||
"test": "pnpm run test:back && pnpm run test:front",
|
|
||||||
"test:back": "pnpm run -F @memegoat/backend test",
|
|
||||||
"test:front": "pnpm run -F @memegoat/frontend test",
|
|
||||||
"format": "pnpm run format:back && pnpm run format:front && pnpm run format:docs",
|
|
||||||
"format:back": "pnpm run -F @memegoat/backend format",
|
|
||||||
"format:front": "pnpm run -F @memegoat/frontend format",
|
|
||||||
"format:docs": "pnpm run -F @memegoat/documentation format",
|
|
||||||
"upgrade": "pnpm dlx taze minor"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": {
|
|
||||||
"name": "Mathis HERRIOT",
|
|
||||||
"email": "mherriot.pro@proton.me",
|
|
||||||
"role": "Author"
|
|
||||||
},
|
|
||||||
"license": "AGPL-3.0-only",
|
|
||||||
"devDependencies": {
|
|
||||||
"@biomejs/biome": "2.3.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
"name": "@memegoat/backend",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "",
|
|
||||||
"author": "",
|
|
||||||
"private": true,
|
|
||||||
"license": "UNLICENSED",
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
".migrations",
|
|
||||||
"drizzle.config.ts"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "nest build",
|
|
||||||
"lint": "biome check",
|
|
||||||
"lint:write": "biome check --write",
|
|
||||||
"format": "biome format --write",
|
|
||||||
"start": "nest start",
|
|
||||||
"start:dev": "nest start --watch",
|
|
||||||
"start:debug": "nest start --debug --watch",
|
|
||||||
"start:prod": "node dist/main",
|
|
||||||
"test": "jest",
|
|
||||||
"test:watch": "jest --watch",
|
|
||||||
"test:cov": "jest --coverage",
|
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
|
||||||
"db:generate": "drizzle-kit generate",
|
|
||||||
"db:migrate": "drizzle-kit migrate",
|
|
||||||
"db:studio": "drizzle-kit studio"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
|
||||||
"@nestjs/cache-manager": "^3.1.0",
|
|
||||||
"@nestjs/common": "^11.0.1",
|
|
||||||
"@nestjs/config": "^4.0.2",
|
|
||||||
"@nestjs/core": "^11.0.1",
|
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
|
||||||
"@nestjs/schedule": "^6.1.0",
|
|
||||||
"@nestjs/throttler": "^6.5.0",
|
|
||||||
"@noble/post-quantum": "^0.5.4",
|
|
||||||
"@node-rs/argon2": "^2.0.2",
|
|
||||||
"@sentry/nestjs": "^10.32.1",
|
|
||||||
"@sentry/profiling-node": "^10.32.1",
|
|
||||||
"cache-manager": "^7.2.7",
|
|
||||||
"cache-manager-redis-yet": "^5.1.5",
|
|
||||||
"clamscan": "^2.4.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.14.3",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"iron-session": "^8.0.4",
|
|
||||||
"jose": "^6.1.3",
|
|
||||||
"minio": "^8.0.6",
|
|
||||||
"nodemailer": "^7.0.12",
|
|
||||||
"otplib": "^12.0.1",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
|
||||||
"rxjs": "^7.8.1",
|
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"zod": "^4.3.5",
|
|
||||||
"drizzle-kit": "^0.31.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nestjs/cli": "^11.0.0",
|
|
||||||
"globals": "^16.0.0",
|
|
||||||
"jest": "^30.0.0",
|
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"supertest": "^7.0.0",
|
|
||||||
"ts-jest": "^29.2.5",
|
|
||||||
"ts-loader": "^9.5.2",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"typescript-eslint": "^8.20.0",
|
|
||||||
"@nestjs/schematics": "^11.0.0",
|
|
||||||
"@nestjs/testing": "^11.0.1",
|
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
|
||||||
"@types/jest": "^30.0.0",
|
|
||||||
"@types/multer": "^2.0.0",
|
|
||||||
"@types/node": "^22.10.7",
|
|
||||||
"@types/nodemailer": "^7.0.4",
|
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/qrcode": "^1.5.6",
|
|
||||||
"@types/sharp": "^0.32.0",
|
|
||||||
"@types/supertest": "^6.0.2",
|
|
||||||
"@types/uuid": "^11.0.0",
|
|
||||||
"drizzle-kit": "^0.31.8"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"rootDir": "src",
|
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"**/*.(t|j)s"
|
|
||||||
],
|
|
||||||
"coverageDirectory": "../coverage",
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"transformIgnorePatterns": [
|
|
||||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
|
||||||
],
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
|
||||||
},
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"^@noble/post-quantum/(.*)$": "<rootDir>/../node_modules/@noble/post-quantum/$1",
|
|
||||||
"^@noble/hashes/(.*)$": "<rootDir>/../node_modules/@noble/hashes/$1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
"name": "@memegoat/frontend",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "biome check",
|
|
||||||
"format": "biome format --write"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@hookform/resolvers": "^5.2.2",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"axios": "^1.13.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.562.0",
|
|
||||||
"next": "16.1.1",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"react": "19.2.3",
|
|
||||||
"react-day-picker": "^9.13.0",
|
|
||||||
"react-dom": "19.2.3",
|
|
||||||
"react-hook-form": "^7.71.1",
|
|
||||||
"react-resizable-panels": "^4.4.1",
|
|
||||||
"recharts": "2.15.4",
|
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"zod": "^4.3.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@biomejs/biome": "2.3.11",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
REAC_CDA_V04_24052023.pdf
Normal file
BIN
REAC_CDA_V04_24052023.pdf
Normal file
Binary file not shown.
26
README.md
26
README.md
@@ -59,12 +59,28 @@ Pour approfondir vos connaissances techniques sur le projet :
|
|||||||
|
|
||||||
## Comment l'utiliser ?
|
## Comment l'utiliser ?
|
||||||
|
|
||||||
### Installation locale
|
### Déploiement en Production
|
||||||
|
|
||||||
1. Clonez le dépôt.
|
Le projet est prêt pour la production via Docker Compose.
|
||||||
2. Installez les dépendances avec `pnpm install`.
|
|
||||||
3. Configurez les variables d'environnement (voir `.env.example`).
|
1. **Prérequis** : Docker et Docker Compose installés.
|
||||||
4. Lancez les services via Docker ou manuellement.
|
2. **Variables d'environnement** : Copiez `.env.example` en `.env.prod` et ajustez les valeurs (clés secrètes, hosts, Sentry DSN, etc.).
|
||||||
|
3. **Lancement** :
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
4. **Services inclus** :
|
||||||
|
- **Frontend** : Next.js en mode standalone optimisé.
|
||||||
|
- **Backend** : NestJS avec clustering et monitoring Sentry.
|
||||||
|
- **Caddy** : Gestion automatique du SSL/TLS.
|
||||||
|
- **ClamAV** : Scan antivirus en temps réel des médias.
|
||||||
|
- **Redis** : Cache, sessions et limitation de débit (Throttling/Bot detection).
|
||||||
|
- **MinIO** : Stockage compatible S3.
|
||||||
|
|
||||||
|
### Sécurité et Performance
|
||||||
|
- **Transcodage Auto** : Toutes les images sont converties en WebP et les vidéos en WebM pour minimiser la bande passante.
|
||||||
|
- **Bot Detection** : Système intégré de détection et de bannissement automatique des crawlers malveillants via Redis.
|
||||||
|
- **Monitoring** : Tracking d'erreurs et profilage de performance via Sentry (Node.js et Next.js).
|
||||||
|
|
||||||
### Clés API
|
### Clés API
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,4 @@ Ce document définit les objectifs, les critères techniques et les fonctionnali
|
|||||||
- [ ] Tests d'intégration et E2E
|
- [ ] Tests d'intégration et E2E
|
||||||
- [x] Gestion centralisée des erreurs (Filters NestJS)
|
- [x] Gestion centralisée des erreurs (Filters NestJS)
|
||||||
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
|
- [ ] Monitoring et centralisation des logs (ex: Sentry, ELK/Loki)
|
||||||
- [ ] Performance : Cache (Redis) pour les tendances et recherches fréquentes
|
- [x] Performance : Cache (Redis) pour les tendances et recherches fréquentes
|
||||||
|
|||||||
756
backend.plantuml
Normal file
756
backend.plantuml
Normal file
@@ -0,0 +1,756 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
!theme plain
|
||||||
|
top to bottom direction
|
||||||
|
skinparam linetype ortho
|
||||||
|
|
||||||
|
class AdminController {
|
||||||
|
constructor(adminService: AdminService):
|
||||||
|
getStats(): Promise<{users: number, contents: numbe…
|
||||||
|
}
|
||||||
|
class AdminModule
|
||||||
|
class AdminService {
|
||||||
|
constructor(usersRepository: UsersRepository, contentsRepository: ContentsRepository, categoriesRepository: CategoriesRepository):
|
||||||
|
getStats(): Promise<{users: number, contents: numbe…
|
||||||
|
}
|
||||||
|
class AllExceptionsFilter {
|
||||||
|
logger: Logger
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void
|
||||||
|
}
|
||||||
|
class ApiKeysController {
|
||||||
|
constructor(apiKeysService: ApiKeysService):
|
||||||
|
create(req: AuthenticatedRequest, createApiKeyDto: CreateApiKeyDto): Promise<{name: string, key: string, exp…
|
||||||
|
findAll(req: AuthenticatedRequest): Promise<any>
|
||||||
|
revoke(req: AuthenticatedRequest, id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class ApiKeysModule
|
||||||
|
class ApiKeysRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {userId: string; name: string; prefix: string; keyHash: string; expiresAt?: Date}): Promise<any>
|
||||||
|
findAll(userId: string): Promise<any>
|
||||||
|
revoke(userId: string, keyId: string): Promise<any>
|
||||||
|
findActiveByKeyHash(keyHash: string): Promise<any>
|
||||||
|
updateLastUsed(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class ApiKeysService {
|
||||||
|
constructor(apiKeysRepository: ApiKeysRepository, hashingService: HashingService):
|
||||||
|
logger: Logger
|
||||||
|
create(userId: string, name: string, expiresAt?: Date): Promise<{name: string, key: string, exp…
|
||||||
|
findAll(userId: string): Promise<any>
|
||||||
|
revoke(userId: string, keyId: string): Promise<any>
|
||||||
|
validateKey(key: string): Promise<any>
|
||||||
|
}
|
||||||
|
class AppController {
|
||||||
|
constructor(appService: AppService):
|
||||||
|
getHello(): string
|
||||||
|
}
|
||||||
|
class AppModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void
|
||||||
|
}
|
||||||
|
class AppService {
|
||||||
|
getHello(): string
|
||||||
|
}
|
||||||
|
class AuditLogInDb
|
||||||
|
class AuthController {
|
||||||
|
constructor(authService: AuthService, bootstrapService: BootstrapService, configService: ConfigService):
|
||||||
|
register(registerDto: RegisterDto): Promise<{message: string, userId: any}>
|
||||||
|
login(loginDto: LoginDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
verifyTwoFactor(verify2faDto: Verify2faDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
refresh(req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
logout(req: Request, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
bootstrapAdmin(token: string, username: string): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class AuthGuard {
|
||||||
|
constructor(jwtService: JwtService, configService: ConfigService):
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>
|
||||||
|
}
|
||||||
|
class AuthModule
|
||||||
|
class AuthService {
|
||||||
|
constructor(usersService: UsersService, hashingService: HashingService, jwtService: JwtService, sessionsService: SessionsService, configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
generateTwoFactorSecret(userId: string): Promise<{secret: string, qrCodeDataUrl:…
|
||||||
|
enableTwoFactor(userId: string, token: string): Promise<{message: string}>
|
||||||
|
disableTwoFactor(userId: string, token: string): Promise<{message: string}>
|
||||||
|
register(dto: RegisterDto): Promise<{message: string, userId: any}>
|
||||||
|
login(dto: LoginDto, userAgent?: string, ip?: string): Promise<{message: string, requires2FA: …
|
||||||
|
verifyTwoFactorLogin(userId: string, token: string, userAgent?: string, ip?: string): Promise<{message: string, access_token:…
|
||||||
|
refresh(refreshToken: string): Promise<{access_token: string, refresh_…
|
||||||
|
logout(): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class AuthenticatedRequest {
|
||||||
|
user: {sub: string, username: string}
|
||||||
|
}
|
||||||
|
class BootstrapService {
|
||||||
|
constructor(rbacService: RbacService, usersService: UsersService, configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
bootstrapToken: string | null
|
||||||
|
onApplicationBootstrap(): Promise<void>
|
||||||
|
generateBootstrapToken(): void
|
||||||
|
consumeToken(token: string, username: string): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class CategoriesController {
|
||||||
|
constructor(categoriesService: CategoriesService):
|
||||||
|
findAll(): Promise<any>
|
||||||
|
findOne(id: string): Promise<any>
|
||||||
|
create(createCategoryDto: CreateCategoryDto): Promise<any>
|
||||||
|
update(id: string, updateCategoryDto: UpdateCategoryDto): Promise<any>
|
||||||
|
remove(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class CategoriesModule
|
||||||
|
class CategoriesRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findAll(): Promise<any>
|
||||||
|
countAll(): Promise<number>
|
||||||
|
findOne(id: string): Promise<any>
|
||||||
|
create(data: CreateCategoryDto & {slug: string}): Promise<any>
|
||||||
|
update(id: string, data: UpdateCategoryDto & {slug?: string; updatedAt: Date}): Promise<any>
|
||||||
|
remove(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class CategoriesService {
|
||||||
|
constructor(categoriesRepository: CategoriesRepository, cacheManager: Cache):
|
||||||
|
logger: Logger
|
||||||
|
clearCategoriesCache(): Promise<void>
|
||||||
|
findAll(): Promise<any>
|
||||||
|
findOne(id: string): Promise<any>
|
||||||
|
create(data: CreateCategoryDto): Promise<any>
|
||||||
|
update(id: string, data: UpdateCategoryDto): Promise<any>
|
||||||
|
remove(id: string): Promise<any>
|
||||||
|
}
|
||||||
|
class CategoryInDb
|
||||||
|
class ClamScanner {
|
||||||
|
scanStream(stream: Readable): Promise<{isInfected: boolean, viruses: …
|
||||||
|
}
|
||||||
|
class CommonModule
|
||||||
|
class ContentInDb
|
||||||
|
class ContentType {
|
||||||
|
MEME:
|
||||||
|
GIF:
|
||||||
|
}
|
||||||
|
class ContentsController {
|
||||||
|
constructor(contentsService: ContentsService):
|
||||||
|
create(req: AuthenticatedRequest, createContentDto: CreateContentDto): Promise<any>
|
||||||
|
getUploadUrl(req: AuthenticatedRequest, fileName: string): Promise<{url: string, key: string}>
|
||||||
|
upload(req: AuthenticatedRequest, file: Express.Multer.File, uploadContentDto: UploadContentDto): Promise<any>
|
||||||
|
explore(req: AuthenticatedRequest, limit: number, offset: number, sort?: "trend" | "recent", tag?: string, category?: string, author?: string): Promise<{data: any, totalCount: any}>
|
||||||
|
trends(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
recent(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
findOne(idOrSlug: string, req: AuthenticatedRequest, res: Response): Promise<Response<any, Record<string, an…
|
||||||
|
incrementViews(id: string): Promise<void>
|
||||||
|
incrementUsage(id: string): Promise<void>
|
||||||
|
update(id: string, req: AuthenticatedRequest, updateContentDto: any): Promise<any>
|
||||||
|
remove(id: string, req: AuthenticatedRequest): Promise<any>
|
||||||
|
removeAdmin(id: string): Promise<any>
|
||||||
|
updateAdmin(id: string, updateContentDto: any): Promise<any>
|
||||||
|
}
|
||||||
|
class ContentsModule
|
||||||
|
class ContentsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findAll(options: FindAllOptions): Promise<any>
|
||||||
|
create(data: NewContentInDb & {userId: string}, tagNames?: string[]): Promise<any>
|
||||||
|
findOne(idOrSlug: string, userId?: string): Promise<any>
|
||||||
|
count(options: {tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<number>
|
||||||
|
incrementViews(id: string): Promise<void>
|
||||||
|
incrementUsage(id: string): Promise<void>
|
||||||
|
softDelete(id: string, userId: string): Promise<any>
|
||||||
|
softDeleteAdmin(id: string): Promise<any>
|
||||||
|
update(id: string, data: Partial<typeof contents.$inferInsert>): Promise<any>
|
||||||
|
findBySlug(slug: string): Promise<any>
|
||||||
|
purgeSoftDeleted(before: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class ContentsService {
|
||||||
|
constructor(contentsRepository: ContentsRepository, s3Service: IStorageService, mediaService: IMediaService, configService: ConfigService, cacheManager: Cache):
|
||||||
|
logger: Logger
|
||||||
|
clearContentsCache(): Promise<void>
|
||||||
|
getUploadUrl(userId: string, fileName: string): Promise<{url: string, key: string}>
|
||||||
|
uploadAndProcess(userId: string, file: Express.Multer.File, data: UploadContentDto): Promise<any>
|
||||||
|
findAll(options: {limit: number; offset: number; sortBy?: "trend" | "recent"; tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<{data: any, totalCount: any}>
|
||||||
|
create(userId: string, data: CreateContentDto): Promise<any>
|
||||||
|
incrementViews(id: string): Promise<void>
|
||||||
|
incrementUsage(id: string): Promise<void>
|
||||||
|
remove(id: string, userId: string): Promise<any>
|
||||||
|
removeAdmin(id: string): Promise<any>
|
||||||
|
updateAdmin(id: string, data: any): Promise<any>
|
||||||
|
update(id: string, userId: string, data: any): Promise<any>
|
||||||
|
findOne(idOrSlug: string, userId?: string): Promise<any>
|
||||||
|
generateBotHtml(content: {title: string; storageKey: string}): string
|
||||||
|
generateSlug(text: string): string
|
||||||
|
ensureUniqueSlug(title: string): Promise<string>
|
||||||
|
}
|
||||||
|
class CrawlerDetectionMiddleware {
|
||||||
|
logger: Logger
|
||||||
|
SUSPICIOUS_PATTERNS: RegExp[]
|
||||||
|
BOT_USER_AGENTS: RegExp[]
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void
|
||||||
|
}
|
||||||
|
class CreateApiKeyDto {
|
||||||
|
name: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
class CreateCategoryDto {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
iconUrl: string
|
||||||
|
}
|
||||||
|
class CreateContentDto {
|
||||||
|
type: "meme" | "gif"
|
||||||
|
title: string
|
||||||
|
storageKey: string
|
||||||
|
mimeType: string
|
||||||
|
fileSize: number
|
||||||
|
categoryId: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
class CreateReportDto {
|
||||||
|
contentId: string
|
||||||
|
tagId: string
|
||||||
|
reason: "inappropriate" | "spam" | "copyright" …
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
class CryptoModule
|
||||||
|
class CryptoService {
|
||||||
|
constructor(hashingService: HashingService, jwtService: JwtService, encryptionService: EncryptionService, postQuantumService: PostQuantumService):
|
||||||
|
hashEmail(email: string): Promise<string>
|
||||||
|
hashIp(ip: string): Promise<string>
|
||||||
|
getPgpEncryptionKey(): string
|
||||||
|
hashPassword(password: string): Promise<string>
|
||||||
|
verifyPassword(password: string, hash: string): Promise<boolean>
|
||||||
|
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
|
||||||
|
verifyJwt(token: string): Promise<T>
|
||||||
|
encryptContent(content: string): Promise<string>
|
||||||
|
decryptContent(jwe: string): Promise<string>
|
||||||
|
signContent(content: string): Promise<string>
|
||||||
|
verifyContentSignature(jws: string): Promise<string>
|
||||||
|
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
|
||||||
|
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
|
||||||
|
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
|
||||||
|
}
|
||||||
|
class DatabaseModule
|
||||||
|
class DatabaseService {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
pool: Pool
|
||||||
|
db: ReturnType<typeof drizzle>
|
||||||
|
onModuleInit(): Promise<void>
|
||||||
|
onModuleDestroy(): Promise<void>
|
||||||
|
getDatabaseConnectionString(): string
|
||||||
|
}
|
||||||
|
class EncryptionService {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
jwtSecret: Uint8Array
|
||||||
|
encryptionKey: Uint8Array
|
||||||
|
encryptContent(content: string): Promise<string>
|
||||||
|
decryptContent(jwe: string): Promise<string>
|
||||||
|
signContent(content: string): Promise<string>
|
||||||
|
verifyContentSignature(jws: string): Promise<string>
|
||||||
|
getPgpEncryptionKey(): string
|
||||||
|
}
|
||||||
|
class Env
|
||||||
|
class FavoriteInDb
|
||||||
|
class FavoritesController {
|
||||||
|
constructor(favoritesService: FavoritesService):
|
||||||
|
add(req: AuthenticatedRequest, contentId: string): Promise<any>
|
||||||
|
remove(req: AuthenticatedRequest, contentId: string): Promise<any>
|
||||||
|
list(req: AuthenticatedRequest, limit: number, offset: number): Promise<any>
|
||||||
|
}
|
||||||
|
class FavoritesModule
|
||||||
|
class FavoritesRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findContentById(contentId: string): Promise<any>
|
||||||
|
add(userId: string, contentId: string): Promise<any>
|
||||||
|
remove(userId: string, contentId: string): Promise<any>
|
||||||
|
findByUserId(userId: string, limit: number, offset: number): Promise<any>
|
||||||
|
}
|
||||||
|
class FavoritesService {
|
||||||
|
constructor(favoritesRepository: FavoritesRepository):
|
||||||
|
logger: Logger
|
||||||
|
addFavorite(userId: string, contentId: string): Promise<any>
|
||||||
|
removeFavorite(userId: string, contentId: string): Promise<any>
|
||||||
|
getUserFavorites(userId: string, limit: number, offset: number): Promise<any>
|
||||||
|
}
|
||||||
|
class FindAllOptions {
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
sortBy: "trend" | "recent"
|
||||||
|
tag: string
|
||||||
|
category: string
|
||||||
|
author: string
|
||||||
|
query: string
|
||||||
|
favoritesOnly: boolean
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
class HTTPLoggerMiddleware {
|
||||||
|
logger: Logger
|
||||||
|
use(request: Request, response: Response, next: NextFunction): void
|
||||||
|
}
|
||||||
|
class HashingService {
|
||||||
|
hashEmail(email: string): Promise<string>
|
||||||
|
hashIp(ip: string): Promise<string>
|
||||||
|
hashSha256(text: string): Promise<string>
|
||||||
|
hashPassword(password: string): Promise<string>
|
||||||
|
verifyPassword(password: string, hash: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
class HealthController {
|
||||||
|
constructor(databaseService: DatabaseService, cacheManager: Cache):
|
||||||
|
check(): Promise<any>
|
||||||
|
}
|
||||||
|
class IMailService {
|
||||||
|
sendEmailValidation(email: string, token: string): Promise<void>
|
||||||
|
sendPasswordReset(email: string, token: string): Promise<void>
|
||||||
|
}
|
||||||
|
class IMediaProcessorStrategy {
|
||||||
|
canHandle(mimeType: string): boolean
|
||||||
|
process(buffer: Buffer, options?: Record<string, unknown>): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class IMediaService {
|
||||||
|
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
|
||||||
|
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
|
||||||
|
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class IStorageService {
|
||||||
|
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Record<string, string>, bucketName?: string): Promise<string>
|
||||||
|
getFile(fileName: string, bucketName?: string): Promise<Readable>
|
||||||
|
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
deleteFile(fileName: string, bucketName?: string): Promise<void>
|
||||||
|
getFileInfo(fileName: string, bucketName?: string): Promise<unknown>
|
||||||
|
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
|
||||||
|
getPublicUrl(storageKey: string): string
|
||||||
|
}
|
||||||
|
class ImageProcessorStrategy {
|
||||||
|
logger: Logger
|
||||||
|
canHandle(mimeType: string): boolean
|
||||||
|
process(buffer: Buffer, options?: {format: "webp" | "avif"; resize?: {width?: number; height?: number}}): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class JwtService {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
jwtSecret: Uint8Array
|
||||||
|
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
|
||||||
|
verifyJwt(token: string): Promise<T>
|
||||||
|
}
|
||||||
|
class LoginDto {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
class MailModule
|
||||||
|
class MailService {
|
||||||
|
constructor(mailerService: MailerService, configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
domain: string
|
||||||
|
sendEmailValidation(email: string, token: string): Promise<void>
|
||||||
|
sendPasswordReset(email: string, token: string): Promise<void>
|
||||||
|
}
|
||||||
|
class MediaController {
|
||||||
|
constructor(s3Service: S3Service):
|
||||||
|
logger: Logger
|
||||||
|
getFile(path: string, res: Response): Promise<void>
|
||||||
|
}
|
||||||
|
class MediaModule
|
||||||
|
class MediaProcessingResult {
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
extension: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
class MediaProcessingResult {
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
extension: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
class MediaService {
|
||||||
|
constructor(configService: ConfigService, imageProcessor: ImageProcessorStrategy, videoProcessor: VideoProcessorStrategy):
|
||||||
|
logger: Logger
|
||||||
|
clamscan: ClamScanner | null
|
||||||
|
isClamAvInitialized: boolean
|
||||||
|
initClamScan(): Promise<void>
|
||||||
|
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
|
||||||
|
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
|
||||||
|
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
class NewAuditLogInDb
|
||||||
|
class NewCategoryInDb
|
||||||
|
class NewContentInDb
|
||||||
|
class NewFavoriteInDb
|
||||||
|
class NewReportInDb
|
||||||
|
class NewTagInDb
|
||||||
|
class NewUserInDb
|
||||||
|
class OptionalAuthGuard {
|
||||||
|
constructor(jwtService: JwtService, configService: ConfigService):
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>
|
||||||
|
}
|
||||||
|
class PostQuantumService {
|
||||||
|
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
|
||||||
|
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
|
||||||
|
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
|
||||||
|
}
|
||||||
|
class PurgeService {
|
||||||
|
constructor(sessionsRepository: SessionsRepository, reportsRepository: ReportsRepository, usersRepository: UsersRepository, contentsRepository: ContentsRepository):
|
||||||
|
logger: Logger
|
||||||
|
purgeExpiredData(): Promise<void>
|
||||||
|
}
|
||||||
|
class RbacRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findRolesByUserId(userId: string): Promise<any>
|
||||||
|
findPermissionsByUserId(userId: string): Promise<any[]>
|
||||||
|
countRoles(): Promise<number>
|
||||||
|
countAdmins(): Promise<number>
|
||||||
|
createRole(name: string, slug: string, description?: string): Promise<any>
|
||||||
|
assignRole(userId: string, roleSlug: string): Promise<any>
|
||||||
|
}
|
||||||
|
class RbacService {
|
||||||
|
constructor(rbacRepository: RbacRepository):
|
||||||
|
logger: Logger
|
||||||
|
onApplicationBootstrap(): Promise<void>
|
||||||
|
seedRoles(): Promise<void>
|
||||||
|
getUserRoles(userId: string): Promise<any>
|
||||||
|
getUserPermissions(userId: string): Promise<any[]>
|
||||||
|
countAdmins(): Promise<number>
|
||||||
|
assignRoleToUser(userId: string, roleSlug: string): Promise<any>
|
||||||
|
}
|
||||||
|
class RefreshDto {
|
||||||
|
refresh_token: string
|
||||||
|
}
|
||||||
|
class RegisterDto {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
class ReportInDb
|
||||||
|
class ReportReason {
|
||||||
|
INAPPROPRIATE:
|
||||||
|
SPAM:
|
||||||
|
COPYRIGHT:
|
||||||
|
OTHER:
|
||||||
|
}
|
||||||
|
class ReportStatus {
|
||||||
|
PENDING:
|
||||||
|
REVIEWED:
|
||||||
|
RESOLVED:
|
||||||
|
DISMISSED:
|
||||||
|
}
|
||||||
|
class ReportsController {
|
||||||
|
constructor(reportsService: ReportsService):
|
||||||
|
create(req: AuthenticatedRequest, createReportDto: CreateReportDto): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
updateStatus(id: string, updateReportStatusDto: UpdateReportStatusDto): Promise<any>
|
||||||
|
}
|
||||||
|
class ReportsModule
|
||||||
|
class ReportsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {reporterId: string; contentId?: string; tagId?: string; reason: "inappropriate" | "spam" | "copyright" | "other"; description?: string}): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
|
||||||
|
purgeObsolete(now: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class ReportsService {
|
||||||
|
constructor(reportsRepository: ReportsRepository):
|
||||||
|
logger: Logger
|
||||||
|
create(reporterId: string, data: CreateReportDto): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
|
||||||
|
}
|
||||||
|
class RequestWithUser {
|
||||||
|
user: {sub?: string, username?: string, id?: …
|
||||||
|
}
|
||||||
|
class RolesGuard {
|
||||||
|
constructor(reflector: Reflector, rbacService: RbacService):
|
||||||
|
canActivate(context: ExecutionContext): Promise<boolean>
|
||||||
|
}
|
||||||
|
class S3Module
|
||||||
|
class S3Service {
|
||||||
|
constructor(configService: ConfigService):
|
||||||
|
logger: Logger
|
||||||
|
minioClient: Minio.Client
|
||||||
|
bucketName: string
|
||||||
|
onModuleInit(): Promise<void>
|
||||||
|
ensureBucketExists(bucketName: string): Promise<void>
|
||||||
|
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Minio.ItemBucketMetadata, bucketName?: string): Promise<string>
|
||||||
|
getFile(fileName: string, bucketName?: string): Promise<stream.Readable>
|
||||||
|
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
|
||||||
|
deleteFile(fileName: string, bucketName?: string): Promise<void>
|
||||||
|
getFileInfo(fileName: string, bucketName?: string): Promise<BucketItemStat>
|
||||||
|
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
|
||||||
|
getPublicUrl(storageKey: string): string
|
||||||
|
}
|
||||||
|
class ScanResult {
|
||||||
|
isInfected: boolean
|
||||||
|
virusName: string
|
||||||
|
}
|
||||||
|
class ScanResult {
|
||||||
|
isInfected: boolean
|
||||||
|
virusName: string
|
||||||
|
}
|
||||||
|
class SessionData {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
class SessionsModule
|
||||||
|
class SessionsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {userId: string; refreshToken: string; userAgent?: string; ipHash?: string | null; expiresAt: Date}): Promise<any>
|
||||||
|
findValidByRefreshToken(refreshToken: string): Promise<any>
|
||||||
|
update(sessionId: string, data: Record<string, unknown>): Promise<any>
|
||||||
|
revoke(sessionId: string): Promise<void>
|
||||||
|
revokeAllByUserId(userId: string): Promise<void>
|
||||||
|
purgeExpired(now: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class SessionsService {
|
||||||
|
constructor(sessionsRepository: SessionsRepository, hashingService: HashingService, jwtService: JwtService):
|
||||||
|
createSession(userId: string, userAgent?: string, ip?: string): Promise<any>
|
||||||
|
refreshSession(oldRefreshToken: string): Promise<any>
|
||||||
|
revokeSession(sessionId: string): Promise<void>
|
||||||
|
revokeAllUserSessions(userId: string): Promise<void>
|
||||||
|
}
|
||||||
|
class TagInDb
|
||||||
|
class TagsController {
|
||||||
|
constructor(tagsService: TagsService):
|
||||||
|
findAll(limit: number, offset: number, query?: string, sort?: "popular" | "recent"): Promise<any>
|
||||||
|
}
|
||||||
|
class TagsModule
|
||||||
|
class TagsRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
|
||||||
|
}
|
||||||
|
class TagsService {
|
||||||
|
constructor(tagsRepository: TagsRepository):
|
||||||
|
logger: Logger
|
||||||
|
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
|
||||||
|
}
|
||||||
|
class UpdateCategoryDto
|
||||||
|
class UpdateConsentDto {
|
||||||
|
termsVersion: string
|
||||||
|
privacyVersion: string
|
||||||
|
}
|
||||||
|
class UpdateReportStatusDto {
|
||||||
|
status: "pending" | "reviewed" | "resolved" | "…
|
||||||
|
}
|
||||||
|
class UpdateUserDto {
|
||||||
|
displayName: string
|
||||||
|
bio: string
|
||||||
|
avatarUrl: string
|
||||||
|
status: "active" | "verification" | "suspended"…
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
class UploadContentDto {
|
||||||
|
type: "meme" | "gif"
|
||||||
|
title: string
|
||||||
|
categoryId: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
class UserInDb
|
||||||
|
class UsersController {
|
||||||
|
constructor(usersService: UsersService, authService: AuthService):
|
||||||
|
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
findPublicProfile(username: string): Promise<any>
|
||||||
|
findMe(req: AuthenticatedRequest): Promise<any>
|
||||||
|
exportMe(req: AuthenticatedRequest): Promise<null | {profile: any, contents:…
|
||||||
|
updateMe(req: AuthenticatedRequest, updateUserDto: UpdateUserDto): Promise<any>
|
||||||
|
updateAvatar(req: AuthenticatedRequest, file: Express.Multer.File): Promise<any>
|
||||||
|
updateConsent(req: AuthenticatedRequest, consentDto: UpdateConsentDto): Promise<any>
|
||||||
|
removeMe(req: AuthenticatedRequest): Promise<any>
|
||||||
|
removeAdmin(uuid: string): Promise<any>
|
||||||
|
updateAdmin(uuid: string, updateUserDto: UpdateUserDto): Promise<any>
|
||||||
|
setup2fa(req: AuthenticatedRequest): Promise<{secret: string, qrCodeDataUrl:…
|
||||||
|
enable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
|
||||||
|
disable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
|
||||||
|
}
|
||||||
|
class UsersModule
|
||||||
|
class UsersRepository {
|
||||||
|
constructor(databaseService: DatabaseService):
|
||||||
|
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
|
||||||
|
findByEmailHash(emailHash: string): Promise<any>
|
||||||
|
findOneWithPrivateData(uuid: string): Promise<any>
|
||||||
|
countAll(): Promise<number>
|
||||||
|
findAll(limit: number, offset: number): Promise<any>
|
||||||
|
findByUsername(username: string): Promise<any>
|
||||||
|
findOne(uuid: string): Promise<any>
|
||||||
|
update(uuid: string, data: Partial<typeof users.$inferInsert>): Promise<any>
|
||||||
|
getTwoFactorSecret(uuid: string): Promise<any>
|
||||||
|
getUserContents(uuid: string): Promise<any>
|
||||||
|
getUserFavorites(uuid: string): Promise<any>
|
||||||
|
softDeleteUserAndContents(uuid: string): Promise<any>
|
||||||
|
purgeDeleted(before: Date): Promise<any>
|
||||||
|
}
|
||||||
|
class UsersService {
|
||||||
|
constructor(usersRepository: UsersRepository, cacheManager: Cache, rbacService: RbacService, mediaService: IMediaService, s3Service: IStorageService):
|
||||||
|
logger: Logger
|
||||||
|
clearUserCache(username?: string): Promise<void>
|
||||||
|
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
|
||||||
|
findByEmailHash(emailHash: string): Promise<any>
|
||||||
|
findOneWithPrivateData(uuid: string): Promise<any>
|
||||||
|
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
|
||||||
|
findPublicProfile(username: string): Promise<any>
|
||||||
|
findOne(uuid: string): Promise<any>
|
||||||
|
update(uuid: string, data: UpdateUserDto): Promise<any>
|
||||||
|
updateAvatar(uuid: string, file: Express.Multer.File): Promise<any>
|
||||||
|
updateConsent(uuid: string, termsVersion: string, privacyVersion: string): Promise<any>
|
||||||
|
setTwoFactorSecret(uuid: string, secret: string): Promise<any>
|
||||||
|
toggleTwoFactor(uuid: string, enabled: boolean): Promise<any>
|
||||||
|
getTwoFactorSecret(uuid: string): Promise<string | null>
|
||||||
|
exportUserData(uuid: string): Promise<null | {profile: any, contents:…
|
||||||
|
remove(uuid: string): Promise<any>
|
||||||
|
}
|
||||||
|
class Verify2faDto {
|
||||||
|
userId: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
class VideoProcessorStrategy {
|
||||||
|
logger: Logger
|
||||||
|
canHandle(mimeType: string): boolean
|
||||||
|
process(buffer: Buffer, options?: {format: "webm" | "av1"}): Promise<MediaProcessingResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController -[#595959,dashed]-> AdminService
|
||||||
|
AdminService -[#595959,dashed]-> CategoriesRepository
|
||||||
|
AdminService -[#595959,dashed]-> ContentsRepository
|
||||||
|
AdminService -[#595959,dashed]-> UsersRepository
|
||||||
|
AllExceptionsFilter -[#595959,dashed]-> RequestWithUser
|
||||||
|
ApiKeysController -[#595959,dashed]-> ApiKeysService
|
||||||
|
ApiKeysController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
ApiKeysController -[#595959,dashed]-> CreateApiKeyDto
|
||||||
|
ApiKeysRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
ApiKeysService -[#595959,dashed]-> ApiKeysRepository
|
||||||
|
ApiKeysService -[#595959,dashed]-> ApiKeysService
|
||||||
|
ApiKeysService -[#595959,dashed]-> HashingService
|
||||||
|
AppController -[#595959,dashed]-> AppService
|
||||||
|
AppModule -[#595959,dashed]-> CrawlerDetectionMiddleware
|
||||||
|
AppModule -[#595959,dashed]-> HTTPLoggerMiddleware
|
||||||
|
AuthController -[#595959,dashed]-> AuthService
|
||||||
|
AuthController -[#595959,dashed]-> BootstrapService
|
||||||
|
AuthController -[#595959,dashed]-> LoginDto
|
||||||
|
AuthController -[#595959,dashed]-> RegisterDto
|
||||||
|
AuthController -[#595959,dashed]-> SessionData
|
||||||
|
AuthController -[#595959,dashed]-> Verify2faDto
|
||||||
|
AuthGuard -[#595959,dashed]-> JwtService
|
||||||
|
AuthGuard -[#595959,dashed]-> SessionData
|
||||||
|
AuthService -[#595959,dashed]-> AuthService
|
||||||
|
AuthService -[#595959,dashed]-> HashingService
|
||||||
|
AuthService -[#595959,dashed]-> JwtService
|
||||||
|
AuthService -[#595959,dashed]-> LoginDto
|
||||||
|
AuthService -[#595959,dashed]-> RegisterDto
|
||||||
|
AuthService -[#595959,dashed]-> SessionsService
|
||||||
|
AuthService -[#595959,dashed]-> UsersService
|
||||||
|
BootstrapService -[#595959,dashed]-> BootstrapService
|
||||||
|
BootstrapService -[#595959,dashed]-> RbacService
|
||||||
|
BootstrapService -[#595959,dashed]-> UsersService
|
||||||
|
CategoriesController -[#595959,dashed]-> AuthGuard
|
||||||
|
CategoriesController -[#595959,dashed]-> CategoriesService
|
||||||
|
CategoriesController -[#595959,dashed]-> CreateCategoryDto
|
||||||
|
CategoriesController -[#595959,dashed]-> RolesGuard
|
||||||
|
CategoriesController -[#595959,dashed]-> UpdateCategoryDto
|
||||||
|
CategoriesRepository -[#595959,dashed]-> CreateCategoryDto
|
||||||
|
CategoriesRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
CategoriesRepository -[#595959,dashed]-> UpdateCategoryDto
|
||||||
|
CategoriesService -[#595959,dashed]-> CategoriesRepository
|
||||||
|
CategoriesService -[#595959,dashed]-> CategoriesService
|
||||||
|
CategoriesService -[#595959,dashed]-> CreateCategoryDto
|
||||||
|
CategoriesService -[#595959,dashed]-> UpdateCategoryDto
|
||||||
|
ContentsController -[#595959,dashed]-> AuthGuard
|
||||||
|
ContentsController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
ContentsController -[#595959,dashed]-> ContentsService
|
||||||
|
ContentsController -[#595959,dashed]-> CreateContentDto
|
||||||
|
ContentsController -[#595959,dashed]-> OptionalAuthGuard
|
||||||
|
ContentsController -[#595959,dashed]-> RolesGuard
|
||||||
|
ContentsController -[#595959,dashed]-> UploadContentDto
|
||||||
|
ContentsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
ContentsRepository -[#595959,dashed]-> FindAllOptions
|
||||||
|
ContentsRepository -[#595959,dashed]-> NewContentInDb
|
||||||
|
ContentsService -[#595959,dashed]-> ContentsRepository
|
||||||
|
ContentsService -[#595959,dashed]-> ContentsService
|
||||||
|
ContentsService -[#595959,dashed]-> CreateContentDto
|
||||||
|
ContentsService -[#595959,dashed]-> IMediaService
|
||||||
|
ContentsService -[#595959,dashed]-> IStorageService
|
||||||
|
ContentsService -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
ContentsService -[#595959,dashed]-> MediaService
|
||||||
|
ContentsService -[#595959,dashed]-> S3Service
|
||||||
|
ContentsService -[#595959,dashed]-> UploadContentDto
|
||||||
|
CryptoService -[#595959,dashed]-> EncryptionService
|
||||||
|
CryptoService -[#595959,dashed]-> HashingService
|
||||||
|
CryptoService -[#595959,dashed]-> JwtService
|
||||||
|
CryptoService -[#595959,dashed]-> PostQuantumService
|
||||||
|
DatabaseService -[#595959,dashed]-> DatabaseService
|
||||||
|
EncryptionService -[#595959,dashed]-> EncryptionService
|
||||||
|
FavoritesController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
FavoritesController -[#595959,dashed]-> FavoritesService
|
||||||
|
FavoritesRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
FavoritesService -[#595959,dashed]-> FavoritesRepository
|
||||||
|
FavoritesService -[#595959,dashed]-> FavoritesService
|
||||||
|
HealthController -[#595959,dashed]-> DatabaseService
|
||||||
|
IMediaProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
IMediaService -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
IMediaService -[#595959,dashed]-> ScanResult
|
||||||
|
ImageProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
|
||||||
|
ImageProcessorStrategy -[#595959,dashed]-> ImageProcessorStrategy
|
||||||
|
ImageProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
JwtService -[#595959,dashed]-> JwtService
|
||||||
|
MailService -[#008200,dashed]-^ IMailService
|
||||||
|
MailService -[#595959,dashed]-> MailService
|
||||||
|
MediaController -[#595959,dashed]-> MediaController
|
||||||
|
MediaController -[#595959,dashed]-> S3Service
|
||||||
|
MediaService -[#595959,dashed]-> ClamScanner
|
||||||
|
MediaService -[#008200,dashed]-^ IMediaService
|
||||||
|
MediaService -[#595959,dashed]-> ImageProcessorStrategy
|
||||||
|
MediaService -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
MediaService -[#595959,dashed]-> MediaService
|
||||||
|
MediaService -[#595959,dashed]-> ScanResult
|
||||||
|
MediaService -[#595959,dashed]-> VideoProcessorStrategy
|
||||||
|
OptionalAuthGuard -[#595959,dashed]-> JwtService
|
||||||
|
OptionalAuthGuard -[#595959,dashed]-> SessionData
|
||||||
|
PurgeService -[#595959,dashed]-> ContentsRepository
|
||||||
|
PurgeService -[#595959,dashed]-> PurgeService
|
||||||
|
PurgeService -[#595959,dashed]-> ReportsRepository
|
||||||
|
PurgeService -[#595959,dashed]-> SessionsRepository
|
||||||
|
PurgeService -[#595959,dashed]-> UsersRepository
|
||||||
|
RbacRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
RbacService -[#595959,dashed]-> RbacRepository
|
||||||
|
RbacService -[#595959,dashed]-> RbacService
|
||||||
|
ReportsController -[#595959,dashed]-> AuthGuard
|
||||||
|
ReportsController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
ReportsController -[#595959,dashed]-> CreateReportDto
|
||||||
|
ReportsController -[#595959,dashed]-> ReportsService
|
||||||
|
ReportsController -[#595959,dashed]-> RolesGuard
|
||||||
|
ReportsController -[#595959,dashed]-> UpdateReportStatusDto
|
||||||
|
ReportsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
ReportsService -[#595959,dashed]-> CreateReportDto
|
||||||
|
ReportsService -[#595959,dashed]-> ReportsRepository
|
||||||
|
ReportsService -[#595959,dashed]-> ReportsService
|
||||||
|
RolesGuard -[#595959,dashed]-> RbacService
|
||||||
|
S3Service -[#008200,dashed]-^ IStorageService
|
||||||
|
S3Service -[#595959,dashed]-> S3Service
|
||||||
|
SessionsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
SessionsService -[#595959,dashed]-> HashingService
|
||||||
|
SessionsService -[#595959,dashed]-> JwtService
|
||||||
|
SessionsService -[#595959,dashed]-> SessionsRepository
|
||||||
|
TagsController -[#595959,dashed]-> TagsService
|
||||||
|
TagsRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
TagsService -[#595959,dashed]-> TagsRepository
|
||||||
|
TagsService -[#595959,dashed]-> TagsService
|
||||||
|
UsersController -[#595959,dashed]-> AuthGuard
|
||||||
|
UsersController -[#595959,dashed]-> AuthService
|
||||||
|
UsersController -[#595959,dashed]-> AuthenticatedRequest
|
||||||
|
UsersController -[#595959,dashed]-> RolesGuard
|
||||||
|
UsersController -[#595959,dashed]-> UpdateConsentDto
|
||||||
|
UsersController -[#595959,dashed]-> UpdateUserDto
|
||||||
|
UsersController -[#595959,dashed]-> UsersService
|
||||||
|
UsersRepository -[#595959,dashed]-> DatabaseService
|
||||||
|
UsersService -[#595959,dashed]-> IMediaService
|
||||||
|
UsersService -[#595959,dashed]-> IStorageService
|
||||||
|
UsersService -[#595959,dashed]-> MediaService
|
||||||
|
UsersService -[#595959,dashed]-> RbacService
|
||||||
|
UsersService -[#595959,dashed]-> S3Service
|
||||||
|
UsersService -[#595959,dashed]-> UpdateUserDto
|
||||||
|
UsersService -[#595959,dashed]-> UsersRepository
|
||||||
|
UsersService -[#595959,dashed]-> UsersService
|
||||||
|
VideoProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
|
||||||
|
VideoProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
|
||||||
|
VideoProcessorStrategy -[#595959,dashed]-> VideoProcessorStrategy
|
||||||
|
@enduml
|
||||||
1
backend/.migrations/0007_melodic_synch.sql
Normal file
1
backend/.migrations/0007_melodic_synch.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."content_type" ADD VALUE 'video';
|
||||||
54
backend/.migrations/0008_bitter_darwin.sql
Normal file
54
backend/.migrations/0008_bitter_darwin.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
CREATE TABLE "comment_likes" (
|
||||||
|
"comment_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "comment_likes_comment_id_user_id_pk" PRIMARY KEY("comment_id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"content_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"parent_id" uuid,
|
||||||
|
"text" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "conversation_participants" (
|
||||||
|
"conversation_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "conversation_participants_conversation_id_user_id_pk" PRIMARY KEY("conversation_id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "conversations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"conversation_id" uuid NOT NULL,
|
||||||
|
"sender_id" uuid NOT NULL,
|
||||||
|
"text" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"read_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_uuid_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_content_id_idx" ON "comments" USING btree ("content_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_user_id_idx" ON "comments" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "messages_conversation_id_idx" ON "messages" USING btree ("conversation_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "messages_sender_id_idx" ON "messages" USING btree ("sender_id");
|
||||||
2
backend/.migrations/0009_add_privacy_settings.sql
Normal file
2
backend/.migrations/0009_add_privacy_settings.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "show_online_status" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "show_read_receipts" boolean DEFAULT true NOT NULL;
|
||||||
1
backend/.migrations/0010_update_password_hash_length.sql
Normal file
1
backend/.migrations/0010_update_password_hash_length.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(255);
|
||||||
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2080
backend/.migrations/meta/0008_snapshot.json
Normal file
2080
backend/.migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2094
backend/.migrations/meta/0009_snapshot.json
Normal file
2094
backend/.migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2094
backend/.migrations/meta/0010_snapshot.json
Normal file
2094
backend/.migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,34 @@
|
|||||||
"when": 1768423315172,
|
"when": 1768423315172,
|
||||||
"tag": "0006_friendly_adam_warlock",
|
"tag": "0006_friendly_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769605995410,
|
||||||
|
"tag": "0007_melodic_synch",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769696731978,
|
||||||
|
"tag": "0008_bitter_darwin",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769717126917,
|
||||||
|
"tag": "0009_add_privacy_settings",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769718997591,
|
||||||
|
"tag": "0010_update_password_hash_length",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ ENV PNPM_HOME="/pnpm"
|
|||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
@@ -13,13 +15,13 @@ COPY documentation/package.json ./documentation/
|
|||||||
|
|
||||||
# Utilisation du cache pour pnpm et installation figée
|
# Utilisation du cache pour pnpm et installation figée
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile --force
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Deuxième passe avec cache pour les scripts/liens
|
# Deuxième passe avec cache pour les scripts/liens
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile --force
|
||||||
|
|
||||||
RUN pnpm run --filter @memegoat/backend build
|
RUN pnpm run --filter @memegoat/backend build
|
||||||
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off"
|
"noUnknownAtRules": "off",
|
||||||
|
"noExplicitAny": "off"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"useImportType": "off"
|
"useImportType": "off"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@memegoat/backend",
|
"name": "@memegoat/backend",
|
||||||
"version": "0.0.0",
|
"version": "1.9.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"lint:write": "biome check --write",
|
"lint:write": "biome check --write --unsafe",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
@@ -36,8 +36,10 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.12",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/websockets": "^11.1.12",
|
||||||
"@noble/post-quantum": "^0.5.4",
|
"@noble/post-quantum": "^0.5.4",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@sentry/nestjs": "^10.32.1",
|
"@sentry/nestjs": "^10.32.1",
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
@@ -61,23 +64,12 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5"
|
||||||
"drizzle-kit": "^0.31.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"globals": "^16.0.0",
|
|
||||||
"jest": "^30.0.0",
|
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"supertest": "^7.0.0",
|
|
||||||
"ts-jest": "^29.2.5",
|
|
||||||
"ts-loader": "^9.5.2",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"typescript-eslint": "^8.20.0",
|
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
@@ -89,9 +81,21 @@
|
|||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/sharp": "^0.32.0",
|
"@types/sharp": "^0.32.0",
|
||||||
|
"@types/socket.io": "^3.0.2",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
"drizzle-kit": "^0.31.8"
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
@@ -107,7 +111,7 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid))"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
|
|||||||
62
backend/src/admin/admin.controller.spec.ts
Normal file
62
backend/src/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||||
|
import { AdminController } from "./admin.controller";
|
||||||
|
import { AdminService } from "./admin.service";
|
||||||
|
|
||||||
|
describe("AdminController", () => {
|
||||||
|
let controller: AdminController;
|
||||||
|
let service: AdminService;
|
||||||
|
|
||||||
|
const mockAdminService = {
|
||||||
|
getStats: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [{ provide: AdminService, useValue: mockAdminService }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<AdminController>(AdminController);
|
||||||
|
service = module.get<AdminService>(AdminService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStats", () => {
|
||||||
|
it("should call service.getStats", async () => {
|
||||||
|
await controller.getStats();
|
||||||
|
expect(service.getStats).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
58
backend/src/admin/admin.service.spec.ts
Normal file
58
backend/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { CategoriesRepository } from "../categories/repositories/categories.repository";
|
||||||
|
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||||
|
import { UsersRepository } from "../users/repositories/users.repository";
|
||||||
|
import { AdminService } from "./admin.service";
|
||||||
|
|
||||||
|
describe("AdminService", () => {
|
||||||
|
let service: AdminService;
|
||||||
|
let _usersRepository: UsersRepository;
|
||||||
|
let _contentsRepository: ContentsRepository;
|
||||||
|
let _categoriesRepository: CategoriesRepository;
|
||||||
|
|
||||||
|
const mockUsersRepository = {
|
||||||
|
countAll: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContentsRepository = {
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCategoriesRepository = {
|
||||||
|
countAll: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AdminService,
|
||||||
|
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||||
|
{ provide: ContentsRepository, useValue: mockContentsRepository },
|
||||||
|
{ provide: CategoriesRepository, useValue: mockCategoriesRepository },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AdminService>(AdminService);
|
||||||
|
_usersRepository = module.get<UsersRepository>(UsersRepository);
|
||||||
|
_contentsRepository = module.get<ContentsRepository>(ContentsRepository);
|
||||||
|
_categoriesRepository =
|
||||||
|
module.get<CategoriesRepository>(CategoriesRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return stats", async () => {
|
||||||
|
mockUsersRepository.countAll.mockResolvedValue(10);
|
||||||
|
mockContentsRepository.count.mockResolvedValue(20);
|
||||||
|
mockCategoriesRepository.countAll.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const result = await service.getStats();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
users: 10,
|
||||||
|
contents: 20,
|
||||||
|
categories: 5,
|
||||||
|
});
|
||||||
|
expect(mockUsersRepository.countAll).toHaveBeenCalled();
|
||||||
|
expect(mockContentsRepository.count).toHaveBeenCalledWith({});
|
||||||
|
expect(mockCategoriesRepository.countAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
backend/src/api-keys/api-keys.controller.spec.ts
Normal file
95
backend/src/api-keys/api-keys.controller.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { ApiKeysController } from "./api-keys.controller";
|
||||||
|
import { ApiKeysService } from "./api-keys.service";
|
||||||
|
|
||||||
|
describe("ApiKeysController", () => {
|
||||||
|
let controller: ApiKeysController;
|
||||||
|
let service: ApiKeysService;
|
||||||
|
|
||||||
|
const mockApiKeysService = {
|
||||||
|
create: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
revoke: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [ApiKeysController],
|
||||||
|
providers: [{ provide: ApiKeysService, useValue: mockApiKeysService }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<ApiKeysController>(ApiKeysController);
|
||||||
|
service = module.get<ApiKeysService>(ApiKeysService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should call service.create", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const dto = { name: "Key Name", expiresAt: "2026-01-20T12:00:00Z" };
|
||||||
|
await controller.create(req, dto);
|
||||||
|
expect(service.create).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"Key Name",
|
||||||
|
new Date(dto.expiresAt),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call service.create without expiresAt", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const dto = { name: "Key Name" };
|
||||||
|
await controller.create(req, dto);
|
||||||
|
expect(service.create).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"Key Name",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should call service.findAll", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.findAll(req);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith("user-uuid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("revoke", () => {
|
||||||
|
it("should call service.revoke", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.revoke(req, "key-id");
|
||||||
|
expect(service.revoke).toHaveBeenCalledWith("user-uuid", "key-id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import { ApiKeysRepository } from "./api-keys.repository";
|
||||||
|
|
||||||
|
describe("ApiKeysRepository", () => {
|
||||||
|
let repository: ApiKeysRepository;
|
||||||
|
let _databaseService: DatabaseService;
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
insert: jest.fn().mockReturnThis(),
|
||||||
|
values: jest.fn().mockReturnThis(),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
from: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
returning: jest.fn().mockReturnThis(),
|
||||||
|
limit: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapWithThen = (obj: unknown) => {
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
|
Object.defineProperty(obj, "then", {
|
||||||
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
|
const result = (this as Record<string, unknown>).execute();
|
||||||
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
wrapWithThen(mockDb);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ApiKeysRepository,
|
||||||
|
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repository = module.get<ApiKeysRepository>(ApiKeysRepository);
|
||||||
|
_databaseService = module.get<DatabaseService>(DatabaseService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create an api key", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
await repository.create({
|
||||||
|
userId: "u1",
|
||||||
|
name: "n",
|
||||||
|
prefix: "p",
|
||||||
|
keyHash: "h",
|
||||||
|
});
|
||||||
|
expect(mockDb.insert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find all keys for user", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
const result = await repository.findAll("u1");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should revoke a key", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: "1", isActive: false },
|
||||||
|
]);
|
||||||
|
const result = await repository.revoke("u1", "k1");
|
||||||
|
expect(result[0].isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find active by hash", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
const result = await repository.findActiveByKeyHash("h");
|
||||||
|
expect(result.id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update last used", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
await repository.updateLastUsed("1");
|
||||||
|
expect(mockDb.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { AppController } from "./app.controller";
|
|||||||
import { AppService } from "./app.service";
|
import { AppService } from "./app.service";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CategoriesModule } from "./categories/categories.module";
|
import { CategoriesModule } from "./categories/categories.module";
|
||||||
|
import { CommentsModule } from "./comments/comments.module";
|
||||||
import { CommonModule } from "./common/common.module";
|
import { CommonModule } from "./common/common.module";
|
||||||
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
||||||
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
||||||
@@ -21,6 +22,8 @@ import { FavoritesModule } from "./favorites/favorites.module";
|
|||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { MailModule } from "./mail/mail.module";
|
import { MailModule } from "./mail/mail.module";
|
||||||
import { MediaModule } from "./media/media.module";
|
import { MediaModule } from "./media/media.module";
|
||||||
|
import { MessagesModule } from "./messages/messages.module";
|
||||||
|
import { RealtimeModule } from "./realtime/realtime.module";
|
||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
import { S3Module } from "./s3/s3.module";
|
import { S3Module } from "./s3/s3.module";
|
||||||
import { SessionsModule } from "./sessions/sessions.module";
|
import { SessionsModule } from "./sessions/sessions.module";
|
||||||
@@ -37,12 +40,15 @@ import { UsersModule } from "./users/users.module";
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
|
CommentsModule,
|
||||||
ContentsModule,
|
ContentsModule,
|
||||||
FavoritesModule,
|
FavoritesModule,
|
||||||
TagsModule,
|
TagsModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
|
MessagesModule,
|
||||||
SessionsModule,
|
SessionsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
|
RealtimeModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
|||||||
190
backend/src/auth/auth.controller.spec.ts
Normal file
190
backend/src/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthController } from "./auth.controller";
|
||||||
|
import { AuthService } from "./auth.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
|
|
||||||
|
jest.mock("iron-session", () => ({
|
||||||
|
getIronSession: jest.fn().mockResolvedValue({
|
||||||
|
save: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AuthController", () => {
|
||||||
|
let controller: AuthController;
|
||||||
|
let authService: AuthService;
|
||||||
|
let _configService: ConfigService;
|
||||||
|
|
||||||
|
const mockAuthService = {
|
||||||
|
register: jest.fn(),
|
||||||
|
login: jest.fn(),
|
||||||
|
verifyTwoFactorLogin: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockBootstrapService = {
|
||||||
|
consumeToken: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue("complex_password_at_least_32_characters_long"),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
|
{ provide: BootstrapService, useValue: mockBootstrapService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
authService = module.get<AuthService>(AuthService);
|
||||||
|
_configService = module.get<ConfigService>(ConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("register", () => {
|
||||||
|
it("should call authService.register", async () => {
|
||||||
|
const dto = {
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "password",
|
||||||
|
username: "test",
|
||||||
|
};
|
||||||
|
await controller.register(dto as any);
|
||||||
|
expect(authService.register).toHaveBeenCalledWith(dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login", () => {
|
||||||
|
it("should call authService.login and setup session if success", async () => {
|
||||||
|
const dto = { email: "test@example.com", password: "password" };
|
||||||
|
const req = { ip: "127.0.0.1" } as any;
|
||||||
|
const res = { json: jest.fn() } as any;
|
||||||
|
const loginResult = {
|
||||||
|
access_token: "at",
|
||||||
|
refresh_token: "rt",
|
||||||
|
userId: "1",
|
||||||
|
message: "ok",
|
||||||
|
};
|
||||||
|
mockAuthService.login.mockResolvedValue(loginResult);
|
||||||
|
|
||||||
|
await controller.login(dto as any, "ua", req, res);
|
||||||
|
|
||||||
|
expect(authService.login).toHaveBeenCalledWith(dto, "ua", "127.0.0.1");
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: "ok", userId: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return result if no access_token", async () => {
|
||||||
|
const dto = { email: "test@example.com", password: "password" };
|
||||||
|
const req = { ip: "127.0.0.1" } as any;
|
||||||
|
const res = { json: jest.fn() } as any;
|
||||||
|
const loginResult = { message: "2fa_required", userId: "1" };
|
||||||
|
mockAuthService.login.mockResolvedValue(loginResult);
|
||||||
|
|
||||||
|
await controller.login(dto as any, "ua", req, res);
|
||||||
|
|
||||||
|
expect(res.json).toHaveBeenCalledWith(loginResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyTwoFactor", () => {
|
||||||
|
it("should call authService.verifyTwoFactorLogin and setup session", async () => {
|
||||||
|
const dto = { userId: "1", token: "123456" };
|
||||||
|
const req = { ip: "127.0.0.1" } as any;
|
||||||
|
const res = { json: jest.fn() } as any;
|
||||||
|
const verifyResult = {
|
||||||
|
access_token: "at",
|
||||||
|
refresh_token: "rt",
|
||||||
|
message: "ok",
|
||||||
|
};
|
||||||
|
mockAuthService.verifyTwoFactorLogin.mockResolvedValue(verifyResult);
|
||||||
|
|
||||||
|
await controller.verifyTwoFactor(dto, "ua", req, res);
|
||||||
|
|
||||||
|
expect(authService.verifyTwoFactorLogin).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"123456",
|
||||||
|
"ua",
|
||||||
|
"127.0.0.1",
|
||||||
|
);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: "ok" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refresh", () => {
|
||||||
|
it("should refresh token if session has refresh token", async () => {
|
||||||
|
const { getIronSession } = require("iron-session");
|
||||||
|
const session = { refreshToken: "rt", save: jest.fn() };
|
||||||
|
getIronSession.mockResolvedValue(session);
|
||||||
|
const req = {} as any;
|
||||||
|
const res = { json: jest.fn() } as any;
|
||||||
|
mockAuthService.refresh.mockResolvedValue({
|
||||||
|
access_token: "at2",
|
||||||
|
refresh_token: "rt2",
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.refresh(req, res);
|
||||||
|
|
||||||
|
expect(authService.refresh).toHaveBeenCalledWith("rt");
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: "Token refreshed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 401 if no refresh token", async () => {
|
||||||
|
const { getIronSession } = require("iron-session");
|
||||||
|
const session = { save: jest.fn() };
|
||||||
|
getIronSession.mockResolvedValue(session);
|
||||||
|
const req = {} as any;
|
||||||
|
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as any;
|
||||||
|
|
||||||
|
await controller.refresh(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("logout", () => {
|
||||||
|
it("should destroy session", async () => {
|
||||||
|
const { getIronSession } = require("iron-session");
|
||||||
|
const session = { destroy: jest.fn() };
|
||||||
|
getIronSession.mockResolvedValue(session);
|
||||||
|
const req = {} as any;
|
||||||
|
const res = { json: jest.fn() } as any;
|
||||||
|
|
||||||
|
await controller.logout(req, res);
|
||||||
|
|
||||||
|
expect(session.destroy).toHaveBeenCalled();
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: "User logged out" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { getIronSession } from "iron-session";
|
import { getIronSession } from "iron-session";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
import { LoginDto } from "./dto/login.dto";
|
import { LoginDto } from "./dto/login.dto";
|
||||||
import { RegisterDto } from "./dto/register.dto";
|
import { RegisterDto } from "./dto/register.dto";
|
||||||
import { Verify2faDto } from "./dto/verify-2fa.dto";
|
import { Verify2faDto } from "./dto/verify-2fa.dto";
|
||||||
@@ -13,6 +23,7 @@ import { getSessionOptions, SessionData } from "./session.config";
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
private readonly bootstrapService: BootstrapService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -120,4 +131,12 @@ export class AuthController {
|
|||||||
session.destroy();
|
session.destroy();
|
||||||
return res.json({ message: "User logged out" });
|
return res.json({ message: "User logged out" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("bootstrap-admin")
|
||||||
|
async bootstrapAdmin(
|
||||||
|
@Query("token") token: string,
|
||||||
|
@Query("username") username: string,
|
||||||
|
) {
|
||||||
|
return this.bootstrapService.consumeToken(token, username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SessionsModule } from "../sessions/sessions.module";
|
|||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
import { AuthGuard } from "./guards/auth.guard";
|
import { AuthGuard } from "./guards/auth.guard";
|
||||||
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
|
||||||
import { RolesGuard } from "./guards/roles.guard";
|
import { RolesGuard } from "./guards/roles.guard";
|
||||||
@@ -15,6 +16,7 @@ import { RbacRepository } from "./repositories/rbac.repository";
|
|||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
RbacService,
|
RbacService,
|
||||||
|
BootstrapService,
|
||||||
RbacRepository,
|
RbacRepository,
|
||||||
AuthGuard,
|
AuthGuard,
|
||||||
OptionalAuthGuard,
|
OptionalAuthGuard,
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ describe("AuthService", () => {
|
|||||||
const dto = {
|
const dto = {
|
||||||
username: "test",
|
username: "test",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
password: "password",
|
password: "Password1!",
|
||||||
};
|
};
|
||||||
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
|
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
|
||||||
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
||||||
@@ -165,7 +165,7 @@ describe("AuthService", () => {
|
|||||||
|
|
||||||
describe("login", () => {
|
describe("login", () => {
|
||||||
it("should login a user", async () => {
|
it("should login a user", async () => {
|
||||||
const dto = { email: "test@example.com", password: "password" };
|
const dto = { email: "test@example.com", password: "Password1!" };
|
||||||
const user = {
|
const user = {
|
||||||
uuid: "user-id",
|
uuid: "user-id",
|
||||||
username: "test",
|
username: "test",
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export class AuthService {
|
|||||||
const accessToken = await this.jwtService.generateJwt({
|
const accessToken = await this.jwtService.generateJwt({
|
||||||
sub: user.uuid,
|
sub: user.uuid,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await this.sessionsService.createSession(
|
const session = await this.sessionsService.createSession(
|
||||||
@@ -178,6 +179,7 @@ export class AuthService {
|
|||||||
const accessToken = await this.jwtService.generateJwt({
|
const accessToken = await this.jwtService.generateJwt({
|
||||||
sub: user.uuid,
|
sub: user.uuid,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await this.sessionsService.createSession(
|
const session = await this.sessionsService.createSession(
|
||||||
@@ -205,6 +207,7 @@ export class AuthService {
|
|||||||
const accessToken = await this.jwtService.generateJwt({
|
const accessToken = await this.jwtService.generateJwt({
|
||||||
sub: user.uuid,
|
sub: user.uuid,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
114
backend/src/auth/bootstrap.service.spec.ts
Normal file
114
backend/src/auth/bootstrap.service.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import { BootstrapService } from "./bootstrap.service";
|
||||||
|
import { RbacService } from "./rbac.service";
|
||||||
|
|
||||||
|
describe("BootstrapService", () => {
|
||||||
|
let service: BootstrapService;
|
||||||
|
let rbacService: RbacService;
|
||||||
|
let _usersService: UsersService;
|
||||||
|
|
||||||
|
const mockRbacService = {
|
||||||
|
countAdmins: jest.fn(),
|
||||||
|
assignRoleToUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
findPublicProfile: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
BootstrapService,
|
||||||
|
{ provide: RbacService, useValue: mockRbacService },
|
||||||
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<BootstrapService>(BootstrapService);
|
||||||
|
rbacService = module.get<RbacService>(RbacService);
|
||||||
|
_usersService = module.get<UsersService>(UsersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onApplicationBootstrap", () => {
|
||||||
|
it("should generate a token if no admin exists", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
const generateTokenSpy = jest.spyOn(
|
||||||
|
service as any,
|
||||||
|
"generateBootstrapToken",
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(rbacService.countAdmins).toHaveBeenCalled();
|
||||||
|
expect(generateTokenSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not generate a token if admin exists", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(1);
|
||||||
|
const generateTokenSpy = jest.spyOn(
|
||||||
|
service as any,
|
||||||
|
"generateBootstrapToken",
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(rbacService.countAdmins).toHaveBeenCalled();
|
||||||
|
expect(generateTokenSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consumeToken", () => {
|
||||||
|
it("should throw UnauthorizedException if token is invalid", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
await expect(service.consumeToken("wrong-token", "user1")).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if user not found", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
const token = (service as any).bootstrapToken;
|
||||||
|
|
||||||
|
mockUsersService.findPublicProfile.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.consumeToken(token, "user1")).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should assign admin role and invalidate token on success", async () => {
|
||||||
|
mockRbacService.countAdmins.mockResolvedValue(0);
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
const token = (service as any).bootstrapToken;
|
||||||
|
|
||||||
|
const mockUser = { uuid: "user-uuid", username: "user1" };
|
||||||
|
mockUsersService.findPublicProfile.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.consumeToken(token, "user1");
|
||||||
|
|
||||||
|
expect(rbacService.assignRoleToUser).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect((service as any).bootstrapToken).toBeNull();
|
||||||
|
expect(result.message).toContain("user1 is now an administrator");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
backend/src/auth/bootstrap.service.ts
Normal file
67
backend/src/auth/bootstrap.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnApplicationBootstrap,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import { RbacService } from "./rbac.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BootstrapService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(BootstrapService.name);
|
||||||
|
private bootstrapToken: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly rbacService: RbacService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
const adminCount = await this.rbacService.countAdmins();
|
||||||
|
if (adminCount === 0) {
|
||||||
|
this.generateBootstrapToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateBootstrapToken() {
|
||||||
|
this.bootstrapToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const domain = this.configService.get("DOMAIN_NAME") || "localhost";
|
||||||
|
const protocol = domain.includes("localhost") ? "http" : "https";
|
||||||
|
const url = `${protocol}://${domain}/auth/bootstrap-admin`;
|
||||||
|
|
||||||
|
this.logger.warn("SECURITY ALERT: No administrator found in database.");
|
||||||
|
this.logger.warn(
|
||||||
|
"To create the first administrator, use the following endpoint:",
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
`Endpoint: GET ${url}?token=${this.bootstrapToken}&username=votre_nom_utilisateur`,
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
'Exemple: curl -X GET "http://localhost/auth/bootstrap-admin?token=...&username=..."',
|
||||||
|
);
|
||||||
|
this.logger.warn("This token is one-time use only.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeToken(token: string, username: string) {
|
||||||
|
if (!this.bootstrapToken || token !== this.bootstrapToken) {
|
||||||
|
throw new UnauthorizedException("Invalid or expired bootstrap token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.usersService.findPublicProfile(username);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException(`User ${username} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rbacService.assignRoleToUser(user.uuid, "admin");
|
||||||
|
this.bootstrapToken = null; // One-time use
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`User ${username} has been promoted to administrator via bootstrap token.`,
|
||||||
|
);
|
||||||
|
return { message: `User ${username} is now an administrator` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
IsEmail,
|
IsEmail,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsString,
|
IsString,
|
||||||
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
@@ -10,6 +11,10 @@ export class RegisterDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
|
@Matches(/^[a-z0-9_]+$/, {
|
||||||
|
message:
|
||||||
|
"username must contain only lowercase letters, numbers, and underscores",
|
||||||
|
})
|
||||||
username!: string;
|
username!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -21,5 +26,15 @@ export class RegisterDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
|
@Matches(/[A-Z]/, {
|
||||||
|
message: "password must contain at least one uppercase letter",
|
||||||
|
})
|
||||||
|
@Matches(/[a-z]/, {
|
||||||
|
message: "password must contain at least one lowercase letter",
|
||||||
|
})
|
||||||
|
@Matches(/[0-9]/, { message: "password must contain at least one number" })
|
||||||
|
@Matches(/[^A-Za-z0-9]/, {
|
||||||
|
message: "password must contain at least one special character",
|
||||||
|
})
|
||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
89
backend/src/auth/guards/auth.guard.spec.ts
Normal file
89
backend/src/auth/guards/auth.guard.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { getIronSession } from "iron-session";
|
||||||
|
import { JwtService } from "../../crypto/services/jwt.service";
|
||||||
|
import { AuthGuard } from "./auth.guard";
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({}));
|
||||||
|
jest.mock("iron-session", () => ({
|
||||||
|
getIronSession: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AuthGuard", () => {
|
||||||
|
let guard: AuthGuard;
|
||||||
|
let _jwtService: JwtService;
|
||||||
|
let _configService: ConfigService;
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
verifyJwt: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn().mockReturnValue("session-password"),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthGuard,
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = module.get<AuthGuard>(AuthGuard);
|
||||||
|
_jwtService = module.get<JwtService>(JwtService);
|
||||||
|
_configService = module.get<ConfigService>(ConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for valid token", async () => {
|
||||||
|
const request = { user: null };
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => ({}),
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
(getIronSession as jest.Mock).mockResolvedValue({
|
||||||
|
accessToken: "valid-token",
|
||||||
|
});
|
||||||
|
mockJwtService.verifyJwt.mockResolvedValue({ sub: "user1" });
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(request.user).toEqual({ sub: "user1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if no token", async () => {
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({}),
|
||||||
|
getResponse: () => ({}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
(getIronSession as jest.Mock).mockResolvedValue({});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if token invalid", async () => {
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({}),
|
||||||
|
getResponse: () => ({}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
|
||||||
|
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
84
backend/src/auth/guards/optional-auth.guard.spec.ts
Normal file
84
backend/src/auth/guards/optional-auth.guard.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { getIronSession } from "iron-session";
|
||||||
|
import { JwtService } from "../../crypto/services/jwt.service";
|
||||||
|
import { OptionalAuthGuard } from "./optional-auth.guard";
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({}));
|
||||||
|
jest.mock("iron-session", () => ({
|
||||||
|
getIronSession: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OptionalAuthGuard", () => {
|
||||||
|
let guard: OptionalAuthGuard;
|
||||||
|
let _jwtService: JwtService;
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
verifyJwt: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn().mockReturnValue("session-password"),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OptionalAuthGuard,
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = module.get<OptionalAuthGuard>(OptionalAuthGuard);
|
||||||
|
_jwtService = module.get<JwtService>(JwtService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true and set user for valid token", async () => {
|
||||||
|
const request = { user: null };
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => ({}),
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "valid" });
|
||||||
|
mockJwtService.verifyJwt.mockResolvedValue({ sub: "u1" });
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(request.user).toEqual({ sub: "u1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if no token", async () => {
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({}),
|
||||||
|
getResponse: () => ({}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
(getIronSession as jest.Mock).mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true even if token invalid", async () => {
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ user: null }),
|
||||||
|
getResponse: () => ({}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
(getIronSession as jest.Mock).mockResolvedValue({ accessToken: "invalid" });
|
||||||
|
mockJwtService.verifyJwt.mockRejectedValue(new Error("invalid"));
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(context.switchToHttp().getRequest().user).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
90
backend/src/auth/guards/roles.guard.spec.ts
Normal file
90
backend/src/auth/guards/roles.guard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { RbacService } from "../rbac.service";
|
||||||
|
import { RolesGuard } from "./roles.guard";
|
||||||
|
|
||||||
|
describe("RolesGuard", () => {
|
||||||
|
let guard: RolesGuard;
|
||||||
|
let _reflector: Reflector;
|
||||||
|
let _rbacService: RbacService;
|
||||||
|
|
||||||
|
const mockReflector = {
|
||||||
|
getAllAndOverride: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRbacService = {
|
||||||
|
getUserRoles: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
RolesGuard,
|
||||||
|
{ provide: Reflector, useValue: mockReflector },
|
||||||
|
{ provide: RbacService, useValue: mockRbacService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = module.get<RolesGuard>(RolesGuard);
|
||||||
|
_reflector = module.get<Reflector>(Reflector);
|
||||||
|
_rbacService = module.get<RbacService>(RbacService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if no roles required", async () => {
|
||||||
|
mockReflector.getAllAndOverride.mockReturnValue(null);
|
||||||
|
const context = {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if no user in request", async () => {
|
||||||
|
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
|
||||||
|
const context = {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ user: null }),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if user has required role", async () => {
|
||||||
|
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
|
||||||
|
const context = {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ user: { sub: "u1" } }),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
mockRbacService.getUserRoles.mockResolvedValue(["admin", "user"]);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if user doesn't have required role", async () => {
|
||||||
|
mockReflector.getAllAndOverride.mockReturnValue(["admin"]);
|
||||||
|
const context = {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ user: { sub: "u1" } }),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
|
||||||
|
mockRbacService.getUserRoles.mockResolvedValue(["user"]);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ describe("RbacService", () => {
|
|||||||
const mockRbacRepository = {
|
const mockRbacRepository = {
|
||||||
findRolesByUserId: jest.fn(),
|
findRolesByUserId: jest.fn(),
|
||||||
findPermissionsByUserId: jest.fn(),
|
findPermissionsByUserId: jest.fn(),
|
||||||
|
countRoles: jest.fn(),
|
||||||
|
createRole: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -58,4 +60,35 @@ describe("RbacService", () => {
|
|||||||
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
|
expect(repository.findPermissionsByUserId).toHaveBeenCalledWith(userId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("seedRoles", () => {
|
||||||
|
it("should be called on application bootstrap", async () => {
|
||||||
|
const seedRolesSpy = jest.spyOn(service, "seedRoles");
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
expect(seedRolesSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should seed roles if none exist", async () => {
|
||||||
|
mockRbacRepository.countRoles.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.seedRoles();
|
||||||
|
|
||||||
|
expect(repository.countRoles).toHaveBeenCalled();
|
||||||
|
expect(repository.createRole).toHaveBeenCalledTimes(3);
|
||||||
|
expect(repository.createRole).toHaveBeenCalledWith(
|
||||||
|
"Administrator",
|
||||||
|
"admin",
|
||||||
|
"Full system access",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not seed roles if some already exist", async () => {
|
||||||
|
mockRbacRepository.countRoles.mockResolvedValue(3);
|
||||||
|
|
||||||
|
await service.seedRoles();
|
||||||
|
|
||||||
|
expect(repository.countRoles).toHaveBeenCalled();
|
||||||
|
expect(repository.createRole).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,53 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
|
||||||
import { RbacRepository } from "./repositories/rbac.repository";
|
import { RbacRepository } from "./repositories/rbac.repository";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RbacService {
|
export class RbacService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(RbacService.name);
|
||||||
|
|
||||||
constructor(private readonly rbacRepository: RbacRepository) {}
|
constructor(private readonly rbacRepository: RbacRepository) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
this.logger.log("RbacService initialized, checking roles...");
|
||||||
|
await this.seedRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedRoles() {
|
||||||
|
try {
|
||||||
|
const count = await this.rbacRepository.countRoles();
|
||||||
|
if (count === 0) {
|
||||||
|
this.logger.log("No roles found, seeding default roles...");
|
||||||
|
const defaultRoles = [
|
||||||
|
{
|
||||||
|
name: "Administrator",
|
||||||
|
slug: "admin",
|
||||||
|
description: "Full system access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Moderator",
|
||||||
|
slug: "moderator",
|
||||||
|
description: "Access to moderation tools",
|
||||||
|
},
|
||||||
|
{ name: "User", slug: "user", description: "Standard user access" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const role of defaultRoles) {
|
||||||
|
await this.rbacRepository.createRole(
|
||||||
|
role.name,
|
||||||
|
role.slug,
|
||||||
|
role.description,
|
||||||
|
);
|
||||||
|
this.logger.log(`Created role: ${role.slug}`);
|
||||||
|
}
|
||||||
|
this.logger.log("Default roles seeded successfully.");
|
||||||
|
} else {
|
||||||
|
this.logger.log(`${count} roles already exist, skipping seeding.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error during roles seeding:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getUserRoles(userId: string) {
|
async getUserRoles(userId: string) {
|
||||||
return this.rbacRepository.findRolesByUserId(userId);
|
return this.rbacRepository.findRolesByUserId(userId);
|
||||||
}
|
}
|
||||||
@@ -12,4 +55,12 @@ export class RbacService {
|
|||||||
async getUserPermissions(userId: string) {
|
async getUserPermissions(userId: string) {
|
||||||
return this.rbacRepository.findPermissionsByUserId(userId);
|
return this.rbacRepository.findPermissionsByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countAdmins() {
|
||||||
|
return this.rbacRepository.countAdmins();
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignRoleToUser(userId: string, roleSlug: string) {
|
||||||
|
return this.rbacRepository.assignRole(userId, roleSlug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,52 @@ export class RbacRepository {
|
|||||||
|
|
||||||
return Array.from(new Set(result.map((p) => p.slug)));
|
return Array.from(new Set(result.map((p) => p.slug)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countRoles(): Promise<number> {
|
||||||
|
const result = await this.databaseService.db
|
||||||
|
.select({ count: roles.id })
|
||||||
|
.from(roles);
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async countAdmins(): Promise<number> {
|
||||||
|
const result = await this.databaseService.db
|
||||||
|
.select({ count: usersToRoles.userId })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(roles.slug, "admin"));
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRole(name: string, slug: string, description?: string) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.insert(roles)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignRole(userId: string, roleSlug: string) {
|
||||||
|
const role = await this.databaseService.db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.slug, roleSlug))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role[0]) {
|
||||||
|
throw new Error(`Role with slug ${roleSlug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.databaseService.db
|
||||||
|
.insert(usersToRoles)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
roleId: role[0].id,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
105
backend/src/categories/categories.controller.spec.ts
Normal file
105
backend/src/categories/categories.controller.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||||
|
import { CategoriesController } from "./categories.controller";
|
||||||
|
import { CategoriesService } from "./categories.service";
|
||||||
|
|
||||||
|
describe("CategoriesController", () => {
|
||||||
|
let controller: CategoriesController;
|
||||||
|
let service: CategoriesService;
|
||||||
|
|
||||||
|
const mockCategoriesService = {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCacheManager = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [
|
||||||
|
{ provide: CategoriesService, useValue: mockCategoriesService },
|
||||||
|
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<CategoriesController>(CategoriesController);
|
||||||
|
service = module.get<CategoriesService>(CategoriesService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should call service.findAll", async () => {
|
||||||
|
await controller.findAll();
|
||||||
|
expect(service.findAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOne", () => {
|
||||||
|
it("should call service.findOne", async () => {
|
||||||
|
await controller.findOne("1");
|
||||||
|
expect(service.findOne).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should call service.create", async () => {
|
||||||
|
const dto = { name: "Cat", slug: "cat" };
|
||||||
|
await controller.create(dto);
|
||||||
|
expect(service.create).toHaveBeenCalledWith(dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should call service.update", async () => {
|
||||||
|
const dto = { name: "New Name" };
|
||||||
|
await controller.update("1", dto);
|
||||||
|
expect(service.update).toHaveBeenCalledWith("1", dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should call service.remove", async () => {
|
||||||
|
await controller.remove("1");
|
||||||
|
expect(service.remove).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import { CategoriesRepository } from "./categories.repository";
|
||||||
|
|
||||||
|
describe("CategoriesRepository", () => {
|
||||||
|
let repository: CategoriesRepository;
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
from: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
limit: jest.fn().mockReturnThis(),
|
||||||
|
insert: jest.fn().mockReturnThis(),
|
||||||
|
values: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
delete: jest.fn().mockReturnThis(),
|
||||||
|
returning: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapWithThen = (obj: unknown) => {
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
|
Object.defineProperty(obj, "then", {
|
||||||
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
|
const result = (this as Record<string, unknown>).execute();
|
||||||
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
wrapWithThen(mockDb);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
CategoriesRepository,
|
||||||
|
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repository = module.get<CategoriesRepository>(CategoriesRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find all", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
const result = await repository.findAll();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should count all", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ count: 5 }]);
|
||||||
|
const result = await repository.countAll();
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find one", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
const result = await repository.findOne("1");
|
||||||
|
expect(result.id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
await repository.create({ name: "C", slug: "s" });
|
||||||
|
expect(mockDb.insert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
await repository.update("1", { name: "N", updatedAt: new Date() });
|
||||||
|
expect(mockDb.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
await repository.remove("1");
|
||||||
|
expect(mockDb.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
80
backend/src/comments/comments.controller.ts
Normal file
80
backend/src/comments/comments.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { getIronSession } from "iron-session";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { getSessionOptions } from "../auth/session.config";
|
||||||
|
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { JwtService } from "../crypto/services/jwt.service";
|
||||||
|
import { CommentsService } from "./comments.service";
|
||||||
|
import { CreateCommentDto } from "./dto/create-comment.dto";
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class CommentsController {
|
||||||
|
constructor(
|
||||||
|
private readonly commentsService: CommentsService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get("contents/:contentId/comments")
|
||||||
|
async findAllByContentId(
|
||||||
|
@Param("contentId") contentId: string,
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
// Tentative de récupération de l'utilisateur pour isLiked (optionnel)
|
||||||
|
let userId: string | undefined;
|
||||||
|
try {
|
||||||
|
const session = await getIronSession<any>(
|
||||||
|
req,
|
||||||
|
req.res,
|
||||||
|
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||||
|
);
|
||||||
|
if (session.accessToken) {
|
||||||
|
const payload = await this.jwtService.verifyJwt(session.accessToken);
|
||||||
|
userId = payload.sub;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignorer les erreurs de session
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentsService.findAllByContentId(contentId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("contents/:contentId/comments")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
create(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param("contentId") contentId: string,
|
||||||
|
@Body() dto: CreateCommentDto,
|
||||||
|
) {
|
||||||
|
return this.commentsService.create(req.user.sub, contentId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("comments/:id")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
remove(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
|
||||||
|
const isAdmin = req.user.role === "admin" || req.user.role === "moderator";
|
||||||
|
return this.commentsService.remove(req.user.sub, id, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("comments/:id/like")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
like(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
|
||||||
|
return this.commentsService.like(req.user.sub, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("comments/:id/like")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
unlike(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
|
||||||
|
return this.commentsService.unlike(req.user.sub, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/comments/comments.module.ts
Normal file
22
backend/src/comments/comments.module.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { ContentsModule } from "../contents/contents.module";
|
||||||
|
import { RealtimeModule } from "../realtime/realtime.module";
|
||||||
|
import { S3Module } from "../s3/s3.module";
|
||||||
|
import { CommentsController } from "./comments.controller";
|
||||||
|
import { CommentsService } from "./comments.service";
|
||||||
|
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
|
||||||
|
import { CommentsRepository } from "./repositories/comments.repository";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
S3Module,
|
||||||
|
RealtimeModule,
|
||||||
|
forwardRef(() => ContentsModule),
|
||||||
|
],
|
||||||
|
controllers: [CommentsController],
|
||||||
|
providers: [CommentsService, CommentsRepository, CommentLikesRepository],
|
||||||
|
exports: [CommentsService],
|
||||||
|
})
|
||||||
|
export class CommentsModule {}
|
||||||
151
backend/src/comments/comments.service.spec.ts
Normal file
151
backend/src/comments/comments.service.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { ForbiddenException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
|
import { S3Service } from "../s3/s3.service";
|
||||||
|
import { CommentsService } from "./comments.service";
|
||||||
|
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
|
||||||
|
import { CommentsRepository } from "./repositories/comments.repository";
|
||||||
|
|
||||||
|
describe("CommentsService", () => {
|
||||||
|
let service: CommentsService;
|
||||||
|
let repository: CommentsRepository;
|
||||||
|
|
||||||
|
const mockCommentsRepository = {
|
||||||
|
create: jest.fn(),
|
||||||
|
findAllByContentId: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
findOneEnriched: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCommentLikesRepository = {
|
||||||
|
addLike: jest.fn(),
|
||||||
|
removeLike: jest.fn(),
|
||||||
|
countByCommentId: jest.fn(),
|
||||||
|
isLikedByUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContentsRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockS3Service = {
|
||||||
|
getPublicUrl: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEventsGateway = {
|
||||||
|
sendToContent: jest.fn(),
|
||||||
|
sendToUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
CommentsService,
|
||||||
|
{ provide: CommentsRepository, useValue: mockCommentsRepository },
|
||||||
|
{ provide: CommentLikesRepository, useValue: mockCommentLikesRepository },
|
||||||
|
{ provide: ContentsRepository, useValue: mockContentsRepository },
|
||||||
|
{ provide: S3Service, useValue: mockS3Service },
|
||||||
|
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<CommentsService>(CommentsService);
|
||||||
|
repository = module.get<CommentsRepository>(CommentsRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create a comment", async () => {
|
||||||
|
const userId = "user1";
|
||||||
|
const contentId = "content1";
|
||||||
|
const dto = { text: "Nice meme", parentId: undefined };
|
||||||
|
const createdComment = { id: "c1", ...dto, user: { username: "u1" } };
|
||||||
|
mockCommentsRepository.create.mockResolvedValue(createdComment);
|
||||||
|
mockCommentsRepository.findOneEnriched.mockResolvedValue(createdComment);
|
||||||
|
mockCommentLikesRepository.countByCommentId.mockResolvedValue(0);
|
||||||
|
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await service.create(userId, contentId, dto);
|
||||||
|
expect(result.id).toBe("c1");
|
||||||
|
expect(repository.create).toHaveBeenCalledWith({
|
||||||
|
userId,
|
||||||
|
contentId,
|
||||||
|
text: dto.text,
|
||||||
|
parentId: undefined,
|
||||||
|
});
|
||||||
|
expect(mockEventsGateway.sendToContent).toHaveBeenCalledWith(
|
||||||
|
contentId,
|
||||||
|
"new_comment",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAllByContentId", () => {
|
||||||
|
it("should return comments for a content", async () => {
|
||||||
|
mockCommentsRepository.findAllByContentId.mockResolvedValue([
|
||||||
|
{ id: "c1", user: { avatarUrl: "path" } },
|
||||||
|
]);
|
||||||
|
mockCommentLikesRepository.countByCommentId.mockResolvedValue(5);
|
||||||
|
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(true);
|
||||||
|
mockS3Service.getPublicUrl.mockReturnValue("url");
|
||||||
|
|
||||||
|
const result = await service.findAllByContentId("content1", "u1");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].likesCount).toBe(5);
|
||||||
|
expect(result[0].isLiked).toBe(true);
|
||||||
|
expect(result[0].user.avatarUrl).toBe("url");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should remove comment if owner", async () => {
|
||||||
|
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
|
||||||
|
await service.remove("u1", "c1");
|
||||||
|
expect(repository.delete).toHaveBeenCalledWith("c1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove comment if admin", async () => {
|
||||||
|
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
|
||||||
|
await service.remove("other", "c1", true);
|
||||||
|
expect(repository.delete).toHaveBeenCalledWith("c1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if comment does not exist", async () => {
|
||||||
|
mockCommentsRepository.findOne.mockResolvedValue(null);
|
||||||
|
await expect(service.remove("u1", "c1")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException if not owner and not admin", async () => {
|
||||||
|
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
|
||||||
|
await expect(service.remove("other", "c1")).rejects.toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("like", () => {
|
||||||
|
it("should add like", async () => {
|
||||||
|
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
|
||||||
|
await service.like("u1", "c1");
|
||||||
|
expect(mockCommentLikesRepository.addLike).toHaveBeenCalledWith("c1", "u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlike", () => {
|
||||||
|
it("should remove like", async () => {
|
||||||
|
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
|
||||||
|
await service.unlike("u1", "c1");
|
||||||
|
expect(mockCommentLikesRepository.removeLike).toHaveBeenCalledWith(
|
||||||
|
"c1",
|
||||||
|
"u1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
177
backend/src/comments/comments.service.ts
Normal file
177
backend/src/comments/comments.service.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
|
import { S3Service } from "../s3/s3.service";
|
||||||
|
import type { CreateCommentDto } from "./dto/create-comment.dto";
|
||||||
|
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
|
||||||
|
import { CommentsRepository } from "./repositories/comments.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentsService {
|
||||||
|
constructor(
|
||||||
|
private readonly commentsRepository: CommentsRepository,
|
||||||
|
private readonly commentLikesRepository: CommentLikesRepository,
|
||||||
|
@Inject(forwardRef(() => ContentsRepository))
|
||||||
|
private readonly contentsRepository: ContentsRepository,
|
||||||
|
private readonly s3Service: S3Service,
|
||||||
|
private readonly eventsGateway: EventsGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(userId: string, contentId: string, dto: CreateCommentDto) {
|
||||||
|
const comment = await this.commentsRepository.create({
|
||||||
|
userId,
|
||||||
|
contentId,
|
||||||
|
text: dto.text,
|
||||||
|
parentId: dto.parentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket
|
||||||
|
const enrichedComment = await this.findOneEnriched(comment.id, userId);
|
||||||
|
if (!enrichedComment) return null;
|
||||||
|
|
||||||
|
// Notifier les autres utilisateurs sur ce contenu (room de contenu)
|
||||||
|
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment);
|
||||||
|
|
||||||
|
// Notifications ciblées
|
||||||
|
try {
|
||||||
|
// 1. Notifier l'auteur du post
|
||||||
|
const content = await this.contentsRepository.findOne(contentId);
|
||||||
|
if (content && content.userId !== userId) {
|
||||||
|
this.eventsGateway.sendToUser(content.userId, "notification", {
|
||||||
|
type: "comment",
|
||||||
|
userId: userId,
|
||||||
|
username: enrichedComment.user.username,
|
||||||
|
contentId: contentId,
|
||||||
|
commentId: comment.id,
|
||||||
|
text: `a commenté votre post : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Si c'est une réponse, notifier l'auteur du commentaire parent
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parentComment = await this.commentsRepository.findOne(dto.parentId);
|
||||||
|
if (
|
||||||
|
parentComment &&
|
||||||
|
parentComment.userId !== userId &&
|
||||||
|
(!content || parentComment.userId !== content.userId)
|
||||||
|
) {
|
||||||
|
this.eventsGateway.sendToUser(parentComment.userId, "notification", {
|
||||||
|
type: "reply",
|
||||||
|
userId: userId,
|
||||||
|
username: enrichedComment.user.username,
|
||||||
|
contentId: contentId,
|
||||||
|
commentId: comment.id,
|
||||||
|
text: `a répondu à votre commentaire : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send notification:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrichedComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneEnriched(commentId: string, currentUserId?: string) {
|
||||||
|
const comment = await this.commentsRepository.findOneEnriched(commentId);
|
||||||
|
if (!comment) return null;
|
||||||
|
|
||||||
|
const [likesCount, isLiked] = await Promise.all([
|
||||||
|
this.commentLikesRepository.countByCommentId(comment.id),
|
||||||
|
currentUserId
|
||||||
|
? this.commentLikesRepository.isLikedByUser(comment.id, currentUserId)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comment,
|
||||||
|
likesCount,
|
||||||
|
isLiked,
|
||||||
|
user: {
|
||||||
|
...comment.user,
|
||||||
|
avatarUrl: comment.user.avatarUrl
|
||||||
|
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByContentId(contentId: string, userId?: string) {
|
||||||
|
const comments = await this.commentsRepository.findAllByContentId(contentId);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
comments.map(async (comment) => {
|
||||||
|
const [likesCount, isLiked] = await Promise.all([
|
||||||
|
this.commentLikesRepository.countByCommentId(comment.id),
|
||||||
|
userId
|
||||||
|
? this.commentLikesRepository.isLikedByUser(comment.id, userId)
|
||||||
|
: Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comment,
|
||||||
|
likesCount,
|
||||||
|
isLiked,
|
||||||
|
user: {
|
||||||
|
...comment.user,
|
||||||
|
avatarUrl: comment.user.avatarUrl
|
||||||
|
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(userId: string, commentId: string, isAdmin = false) {
|
||||||
|
const comment = await this.commentsRepository.findOne(commentId);
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException("Comment not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin && comment.userId !== userId) {
|
||||||
|
throw new ForbiddenException("You cannot delete this comment");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.commentsRepository.delete(commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async like(userId: string, commentId: string) {
|
||||||
|
const comment = await this.commentsRepository.findOne(commentId);
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException("Comment not found");
|
||||||
|
}
|
||||||
|
await this.commentLikesRepository.addLike(commentId, userId);
|
||||||
|
|
||||||
|
// Notifier l'auteur du commentaire
|
||||||
|
if (comment.userId !== userId) {
|
||||||
|
try {
|
||||||
|
const liker = await this.findOneEnriched(commentId, userId);
|
||||||
|
this.eventsGateway.sendToUser(comment.userId, "notification", {
|
||||||
|
type: "like_comment",
|
||||||
|
userId: userId,
|
||||||
|
username: liker?.user.username,
|
||||||
|
contentId: comment.contentId,
|
||||||
|
commentId: commentId,
|
||||||
|
text: "a aimé votre commentaire",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send like notification:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlike(userId: string, commentId: string) {
|
||||||
|
const comment = await this.commentsRepository.findOne(commentId);
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException("Comment not found");
|
||||||
|
}
|
||||||
|
await this.commentLikesRepository.removeLike(commentId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/comments/dto/create-comment.dto.ts
Normal file
18
backend/src/comments/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(1000)
|
||||||
|
text!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import { commentLikes } from "../../database/schemas/comment_likes";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentLikesRepository {
|
||||||
|
constructor(private readonly databaseService: DatabaseService) {}
|
||||||
|
|
||||||
|
async addLike(commentId: string, userId: string) {
|
||||||
|
await this.databaseService.db
|
||||||
|
.insert(commentLikes)
|
||||||
|
.values({ commentId, userId })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLike(commentId: string, userId: string) {
|
||||||
|
await this.databaseService.db
|
||||||
|
.delete(commentLikes)
|
||||||
|
.where(
|
||||||
|
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countByCommentId(commentId: string) {
|
||||||
|
const results = await this.databaseService.db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(commentLikes)
|
||||||
|
.where(eq(commentLikes.commentId, commentId));
|
||||||
|
return Number(results[0]?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isLikedByUser(commentId: string, userId: string) {
|
||||||
|
const results = await this.databaseService.db
|
||||||
|
.select()
|
||||||
|
.from(commentLikes)
|
||||||
|
.where(
|
||||||
|
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
|
||||||
|
);
|
||||||
|
return !!results[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/src/comments/repositories/comments.repository.ts
Normal file
75
backend/src/comments/repositories/comments.repository.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import { comments, users } from "../../database/schemas";
|
||||||
|
import type { NewCommentInDb } from "../../database/schemas/comments";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentsRepository {
|
||||||
|
constructor(private readonly databaseService: DatabaseService) {}
|
||||||
|
|
||||||
|
async create(data: NewCommentInDb) {
|
||||||
|
const results = await this.databaseService.db
|
||||||
|
.insert(comments)
|
||||||
|
.values(data)
|
||||||
|
.returning();
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByContentId(contentId: string) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
text: comments.text,
|
||||||
|
parentId: comments.parentId,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
updatedAt: comments.updatedAt,
|
||||||
|
user: {
|
||||||
|
uuid: users.uuid,
|
||||||
|
username: users.username,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.innerJoin(users, eq(comments.userId, users.uuid))
|
||||||
|
.where(and(eq(comments.contentId, contentId), isNull(comments.deletedAt)))
|
||||||
|
.orderBy(desc(comments.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const results = await this.databaseService.db
|
||||||
|
.select()
|
||||||
|
.from(comments)
|
||||||
|
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneEnriched(id: string) {
|
||||||
|
const results = await this.databaseService.db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
text: comments.text,
|
||||||
|
parentId: comments.parentId,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
updatedAt: comments.updatedAt,
|
||||||
|
user: {
|
||||||
|
uuid: users.uuid,
|
||||||
|
username: users.username,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.innerJoin(users, eq(comments.userId, users.uuid))
|
||||||
|
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
await this.databaseService.db
|
||||||
|
.update(comments)
|
||||||
|
.set({ deletedAt: new Date() })
|
||||||
|
.where(eq(comments.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ export interface AuthenticatedRequest extends Request {
|
|||||||
user: {
|
user: {
|
||||||
sub: string;
|
sub: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
role: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { Inject, Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||||
|
import type { Cache } from "cache-manager";
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CrawlerDetectionMiddleware implements NestMiddleware {
|
export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||||
private readonly logger = new Logger("CrawlerDetection");
|
private readonly logger = new Logger("CrawlerDetection");
|
||||||
|
|
||||||
|
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||||
|
|
||||||
private readonly SUSPICIOUS_PATTERNS = [
|
private readonly SUSPICIOUS_PATTERNS = [
|
||||||
/\.env/,
|
/\.env/,
|
||||||
/wp-admin/,
|
/wp-admin/,
|
||||||
@@ -24,7 +28,7 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
|||||||
/db\./,
|
/db\./,
|
||||||
/backup\./,
|
/backup\./,
|
||||||
/cgi-bin/,
|
/cgi-bin/,
|
||||||
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
|
/\.well-known\/security\.txt/,
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly BOT_USER_AGENTS = [
|
private readonly BOT_USER_AGENTS = [
|
||||||
@@ -40,11 +44,21 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
|||||||
/masscan/i,
|
/masscan/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
use(req: Request, res: Response, next: NextFunction) {
|
async use(req: Request, res: Response, next: NextFunction) {
|
||||||
const { method, url, ip } = req;
|
const { method, url, ip } = req;
|
||||||
const userAgent = req.get("user-agent") || "unknown";
|
const userAgent = req.get("user-agent") || "unknown";
|
||||||
|
|
||||||
res.on("finish", () => {
|
// Vérifier si l'IP est bannie
|
||||||
|
const isBanned = await this.cacheManager.get(`banned_ip:${ip}`);
|
||||||
|
if (isBanned) {
|
||||||
|
this.logger.warn(`Banned IP attempt: ${ip} -> ${method} ${url}`);
|
||||||
|
res.status(403).json({
|
||||||
|
message: "Access denied: Your IP has been temporarily banned.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.on("finish", async () => {
|
||||||
if (res.statusCode === 404) {
|
if (res.statusCode === 404) {
|
||||||
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
|
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
|
||||||
pattern.test(url),
|
pattern.test(url),
|
||||||
@@ -57,7 +71,9 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
|
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
|
||||||
);
|
);
|
||||||
// Ici, on pourrait ajouter une logique pour bannir l'IP temporairement via Redis
|
|
||||||
|
// Bannir l'IP pour 24h via Redis
|
||||||
|
await this.cacheManager.set(`banned_ip:${ip}`, true, 86400000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const envSchema = z.object({
|
|||||||
// Media Limits
|
// Media Limits
|
||||||
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
||||||
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
|
MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
|
||||||
|
MAX_VIDEO_SIZE_KB: z.coerce.number().default(10240),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
230
backend/src/contents/contents.controller.spec.ts
Normal file
230
backend/src/contents/contents.controller.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
|
||||||
|
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||||
|
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { ContentsController } from "./contents.controller";
|
||||||
|
import { ContentsService } from "./contents.service";
|
||||||
|
|
||||||
|
describe("ContentsController", () => {
|
||||||
|
let controller: ContentsController;
|
||||||
|
let service: ContentsService;
|
||||||
|
|
||||||
|
const mockContentsService = {
|
||||||
|
create: jest.fn(),
|
||||||
|
getUploadUrl: jest.fn(),
|
||||||
|
uploadAndProcess: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
incrementViews: jest.fn(),
|
||||||
|
incrementUsage: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
removeAdmin: jest.fn(),
|
||||||
|
generateBotHtml: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCacheManager = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [ContentsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: ContentsService, useValue: mockContentsService },
|
||||||
|
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(OptionalAuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<ContentsController>(ContentsController);
|
||||||
|
service = module.get<ContentsService>(ContentsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should call service.create", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const dto = { title: "Title", type: "image" as any };
|
||||||
|
await controller.create(req, dto as any);
|
||||||
|
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUploadUrl", () => {
|
||||||
|
it("should call service.getUploadUrl", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.getUploadUrl(req, "test.jpg");
|
||||||
|
expect(service.getUploadUrl).toHaveBeenCalledWith("user-uuid", "test.jpg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upload", () => {
|
||||||
|
it("should call service.uploadAndProcess", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const file = {} as Express.Multer.File;
|
||||||
|
const dto = { title: "Title" };
|
||||||
|
await controller.upload(req, file, dto as any);
|
||||||
|
expect(service.uploadAndProcess).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
file,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("explore", () => {
|
||||||
|
it("should call service.findAll", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.explore(
|
||||||
|
req,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
"trend",
|
||||||
|
"tag",
|
||||||
|
"cat",
|
||||||
|
"auth",
|
||||||
|
"query",
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith({
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
sortBy: "trend",
|
||||||
|
tag: "tag",
|
||||||
|
category: "cat",
|
||||||
|
author: "auth",
|
||||||
|
query: "query",
|
||||||
|
favoritesOnly: false,
|
||||||
|
userId: "user-uuid",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trends", () => {
|
||||||
|
it("should call service.findAll with trend sort", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.trends(req, 10, 0);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith({
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
sortBy: "trend",
|
||||||
|
userId: "user-uuid",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recent", () => {
|
||||||
|
it("should call service.findAll with recent sort", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.recent(req, 10, 0);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith({
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
sortBy: "recent",
|
||||||
|
userId: "user-uuid",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOne", () => {
|
||||||
|
it("should return json for normal user", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
|
||||||
|
const res = { json: jest.fn(), send: jest.fn() } as any;
|
||||||
|
const content = { id: "1" };
|
||||||
|
mockContentsService.findOne.mockResolvedValue(content);
|
||||||
|
|
||||||
|
await controller.findOne("1", req, res);
|
||||||
|
|
||||||
|
expect(res.json).toHaveBeenCalledWith(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return html for bot", async () => {
|
||||||
|
const req = {
|
||||||
|
user: { sub: "user-uuid" },
|
||||||
|
headers: { "user-agent": "Googlebot" },
|
||||||
|
} as any;
|
||||||
|
const res = { json: jest.fn(), send: jest.fn() } as any;
|
||||||
|
const content = { id: "1" };
|
||||||
|
mockContentsService.findOne.mockResolvedValue(content);
|
||||||
|
mockContentsService.generateBotHtml.mockReturnValue("<html></html>");
|
||||||
|
|
||||||
|
await controller.findOne("1", req, res);
|
||||||
|
|
||||||
|
expect(res.send).toHaveBeenCalledWith("<html></html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if not found", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" }, headers: {} } as any;
|
||||||
|
const res = { json: jest.fn(), send: jest.fn() } as any;
|
||||||
|
mockContentsService.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(controller.findOne("1", req, res)).rejects.toThrow(
|
||||||
|
"Contenu non trouvé",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("incrementViews", () => {
|
||||||
|
it("should call service.incrementViews", async () => {
|
||||||
|
await controller.incrementViews("1");
|
||||||
|
expect(service.incrementViews).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("incrementUsage", () => {
|
||||||
|
it("should call service.incrementUsage", async () => {
|
||||||
|
await controller.incrementUsage("1");
|
||||||
|
expect(service.incrementUsage).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should call service.remove", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.remove("1", req);
|
||||||
|
expect(service.remove).toHaveBeenCalledWith("1", "user-uuid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeAdmin", () => {
|
||||||
|
it("should call service.removeAdmin", async () => {
|
||||||
|
await controller.removeAdmin("1");
|
||||||
|
expect(service.removeAdmin).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseBoolPipe,
|
ParseBoolPipe,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -173,6 +174,16 @@ export class ContentsController {
|
|||||||
return this.contentsService.incrementUsage(id);
|
return this.contentsService.incrementUsage(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() updateContentDto: any,
|
||||||
|
) {
|
||||||
|
return this.contentsService.update(id, req.user.sub, updateContentDto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
||||||
@@ -185,4 +196,11 @@ export class ContentsController {
|
|||||||
removeAdmin(@Param("id") id: string) {
|
removeAdmin(@Param("id") id: string) {
|
||||||
return this.contentsService.removeAdmin(id);
|
return this.contentsService.removeAdmin(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(":id/admin")
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Roles("admin")
|
||||||
|
updateAdmin(@Param("id") id: string, @Body() updateContentDto: any) {
|
||||||
|
return this.contentsService.updateAdmin(id, updateContentDto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { MediaModule } from "../media/media.module";
|
import { MediaModule } from "../media/media.module";
|
||||||
|
import { RealtimeModule } from "../realtime/realtime.module";
|
||||||
import { S3Module } from "../s3/s3.module";
|
import { S3Module } from "../s3/s3.module";
|
||||||
import { ContentsController } from "./contents.controller";
|
import { ContentsController } from "./contents.controller";
|
||||||
import { ContentsService } from "./contents.service";
|
import { ContentsService } from "./contents.service";
|
||||||
import { ContentsRepository } from "./repositories/contents.repository";
|
import { ContentsRepository } from "./repositories/contents.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [S3Module, AuthModule, MediaModule],
|
imports: [S3Module, AuthModule, MediaModule, RealtimeModule],
|
||||||
controllers: [ContentsController],
|
controllers: [ContentsController],
|
||||||
providers: [ContentsService, ContentsRepository],
|
providers: [ContentsService, ContentsRepository],
|
||||||
exports: [ContentsRepository],
|
exports: [ContentsRepository],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BadRequestException } from "@nestjs/common";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from "../media/media.service";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { ContentsService } from "./contents.service";
|
import { ContentsService } from "./contents.service";
|
||||||
import { ContentsRepository } from "./repositories/contents.repository";
|
import { ContentsRepository } from "./repositories/contents.repository";
|
||||||
@@ -23,6 +24,7 @@ describe("ContentsService", () => {
|
|||||||
incrementViews: jest.fn(),
|
incrementViews: jest.fn(),
|
||||||
incrementUsage: jest.fn(),
|
incrementUsage: jest.fn(),
|
||||||
softDelete: jest.fn(),
|
softDelete: jest.fn(),
|
||||||
|
softDeleteAdmin: jest.fn(),
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
findBySlug: jest.fn(),
|
findBySlug: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,10 @@ describe("ContentsService", () => {
|
|||||||
del: jest.fn(),
|
del: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockEventsGateway = {
|
||||||
|
sendToUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
@@ -59,6 +65,7 @@ describe("ContentsService", () => {
|
|||||||
{ provide: MediaService, useValue: mockMediaService },
|
{ provide: MediaService, useValue: mockMediaService },
|
||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||||
|
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -147,4 +154,81 @@ describe("ContentsService", () => {
|
|||||||
expect(result[0].views).toBe(1);
|
expect(result[0].views).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("incrementUsage", () => {
|
||||||
|
it("should increment usage", async () => {
|
||||||
|
mockContentsRepository.incrementUsage.mockResolvedValue([
|
||||||
|
{ id: "1", usageCount: 1 },
|
||||||
|
]);
|
||||||
|
await service.incrementUsage("1");
|
||||||
|
expect(mockContentsRepository.incrementUsage).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should soft delete content", async () => {
|
||||||
|
mockContentsRepository.softDelete.mockResolvedValue({ id: "1" });
|
||||||
|
await service.remove("1", "u1");
|
||||||
|
expect(mockContentsRepository.softDelete).toHaveBeenCalledWith("1", "u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeAdmin", () => {
|
||||||
|
it("should soft delete content without checking owner", async () => {
|
||||||
|
mockContentsRepository.softDeleteAdmin.mockResolvedValue({ id: "1" });
|
||||||
|
await service.removeAdmin("1");
|
||||||
|
expect(mockContentsRepository.softDeleteAdmin).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOne", () => {
|
||||||
|
it("should return content by id", async () => {
|
||||||
|
mockContentsRepository.findOne.mockResolvedValue({
|
||||||
|
id: "1",
|
||||||
|
storageKey: "k",
|
||||||
|
author: { avatarUrl: "a" },
|
||||||
|
});
|
||||||
|
mockS3Service.getPublicUrl.mockReturnValue("url");
|
||||||
|
const result = await service.findOne("1");
|
||||||
|
expect(result.id).toBe("1");
|
||||||
|
expect(result.url).toBe("url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return content by slug", async () => {
|
||||||
|
mockContentsRepository.findOne.mockResolvedValue({
|
||||||
|
id: "1",
|
||||||
|
slug: "s",
|
||||||
|
storageKey: "k",
|
||||||
|
});
|
||||||
|
const result = await service.findOne("s");
|
||||||
|
expect(result.slug).toBe("s");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateBotHtml", () => {
|
||||||
|
it("should generate html with og tags", () => {
|
||||||
|
const content = { title: "Title", storageKey: "k" };
|
||||||
|
mockS3Service.getPublicUrl.mockReturnValue("url");
|
||||||
|
const html = service.generateBotHtml(content as any);
|
||||||
|
expect(html).toContain("<title>Title</title>");
|
||||||
|
expect(html).toContain('content="Title"');
|
||||||
|
expect(html).toContain('content="url"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureUniqueSlug", () => {
|
||||||
|
it("should return original slug if unique", async () => {
|
||||||
|
mockContentsRepository.findBySlug.mockResolvedValue(null);
|
||||||
|
const slug = (service as any).ensureUniqueSlug("My Title");
|
||||||
|
await expect(slug).resolves.toBe("my-title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should append counter if not unique", async () => {
|
||||||
|
mockContentsRepository.findBySlug
|
||||||
|
.mockResolvedValueOnce({ id: "1" })
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
const slug = await (service as any).ensureUniqueSlug("My Title");
|
||||||
|
expect(slug).toBe("my-title-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
} from "../common/interfaces/media.interface";
|
} from "../common/interfaces/media.interface";
|
||||||
import type { IStorageService } from "../common/interfaces/storage.interface";
|
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from "../media/media.service";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { CreateContentDto } from "./dto/create-content.dto";
|
import { CreateContentDto } from "./dto/create-content.dto";
|
||||||
import { UploadContentDto } from "./dto/upload-content.dto";
|
import { UploadContentDto } from "./dto/upload-content.dto";
|
||||||
@@ -29,6 +30,7 @@ export class ContentsService {
|
|||||||
@Inject(MediaService) private readonly mediaService: IMediaService,
|
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||||
|
private readonly eventsGateway: EventsGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async clearContentsCache() {
|
private async clearContentsCache() {
|
||||||
@@ -48,6 +50,11 @@ export class ContentsService {
|
|||||||
data: UploadContentDto,
|
data: UploadContentDto,
|
||||||
) {
|
) {
|
||||||
this.logger.log(`Uploading and processing file for user ${userId}`);
|
this.logger.log(`Uploading and processing file for user ${userId}`);
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "starting",
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
// 0. Validation du format et de la taille
|
// 0. Validation du format et de la taille
|
||||||
const allowedMimeTypes = [
|
const allowedMimeTypes = [
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -55,60 +62,117 @@ export class ContentsService {
|
|||||||
"image/webp",
|
"image/webp",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"video/webm",
|
"video/webm",
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "error",
|
||||||
|
message: "Format de fichier non supporté",
|
||||||
|
});
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.",
|
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGif = file.mimetype === "image/gif";
|
// Autodétermination du type si non fourni ou pour valider
|
||||||
const maxSizeKb = isGif
|
let contentType: "meme" | "gif" | "video" = "meme";
|
||||||
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
|
if (file.mimetype === "image/gif") {
|
||||||
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
contentType = "gif";
|
||||||
|
} else if (file.mimetype.startsWith("video/")) {
|
||||||
|
contentType = "video";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGif = contentType === "gif";
|
||||||
|
const isVideo = contentType === "video";
|
||||||
|
let maxSizeKb: number;
|
||||||
|
|
||||||
|
if (isGif) {
|
||||||
|
maxSizeKb = this.configService.get<number>("MAX_GIF_SIZE_KB", 1024);
|
||||||
|
} else if (isVideo) {
|
||||||
|
maxSizeKb = this.configService.get<number>("MAX_VIDEO_SIZE_KB", 10240);
|
||||||
|
} else {
|
||||||
|
maxSizeKb = this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > maxSizeKb * 1024) {
|
if (file.size > maxSizeKb * 1024) {
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "error",
|
||||||
|
message: "Fichier trop volumineux",
|
||||||
|
});
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`,
|
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Scan Antivirus
|
// 1. Scan Antivirus
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "scanning",
|
||||||
|
progress: 20,
|
||||||
|
});
|
||||||
const scanResult = await this.mediaService.scanFile(
|
const scanResult = await this.mediaService.scanFile(
|
||||||
file.buffer,
|
file.buffer,
|
||||||
file.originalname,
|
file.originalname,
|
||||||
);
|
);
|
||||||
if (scanResult.isInfected) {
|
if (scanResult.isInfected) {
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "error",
|
||||||
|
message: "Fichier infecté",
|
||||||
|
});
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Le fichier est infecté par ${scanResult.virusName}`,
|
`Le fichier est infecté par ${scanResult.virusName}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Transcodage
|
// 2. Transcodage
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "processing",
|
||||||
|
progress: 40,
|
||||||
|
});
|
||||||
let processed: MediaProcessingResult;
|
let processed: MediaProcessingResult;
|
||||||
if (file.mimetype.startsWith("image/")) {
|
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
|
||||||
// Image ou GIF -> WebP (format moderne, bien supporté)
|
// Image -> WebP (format moderne, bien supporté)
|
||||||
processed = await this.mediaService.processImage(file.buffer, "webp");
|
processed = await this.mediaService.processImage(file.buffer, "webp");
|
||||||
} else if (file.mimetype.startsWith("video/")) {
|
} else if (
|
||||||
// Vidéo -> WebM
|
file.mimetype.startsWith("video/") ||
|
||||||
|
file.mimetype === "image/gif"
|
||||||
|
) {
|
||||||
|
// Vidéo ou GIF -> WebM
|
||||||
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException("Format de fichier non supporté");
|
throw new BadRequestException("Format de fichier non supporté");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Upload vers S3
|
// 3. Upload vers S3
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "uploading_s3",
|
||||||
|
progress: 70,
|
||||||
|
});
|
||||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||||
this.logger.log(`File uploaded successfully to S3: ${key}`);
|
this.logger.log(`File uploaded successfully to S3: ${key}`);
|
||||||
|
|
||||||
// 4. Création en base de données
|
// 4. Création en base de données
|
||||||
return await this.create(userId, {
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "saving",
|
||||||
|
progress: 90,
|
||||||
|
});
|
||||||
|
const content = await this.create(userId, {
|
||||||
...data,
|
...data,
|
||||||
|
type: contentType, // Utiliser le type autodéterminé
|
||||||
storageKey: key,
|
storageKey: key,
|
||||||
mimeType: processed.mimeType,
|
mimeType: processed.mimeType,
|
||||||
fileSize: processed.size,
|
fileSize: processed.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||||
|
status: "completed",
|
||||||
|
progress: 100,
|
||||||
|
contentId: content.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(options: {
|
async findAll(options: {
|
||||||
@@ -184,6 +248,35 @@ export class ContentsService {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateAdmin(id: string, data: any) {
|
||||||
|
this.logger.log(`Updating content ${id} by admin`);
|
||||||
|
const updated = await this.contentsRepository.update(id, data);
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
await this.clearContentsCache();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, userId: string, data: any) {
|
||||||
|
this.logger.log(`Updating content ${id} for user ${userId}`);
|
||||||
|
|
||||||
|
// Vérifier que le contenu appartient à l'utilisateur
|
||||||
|
const existing = await this.contentsRepository.findOne(id, userId);
|
||||||
|
if (!existing || existing.userId !== userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Contenu non trouvé ou vous n'avez pas la permission de le modifier.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.contentsRepository.update(id, data);
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
await this.clearContentsCache();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(idOrSlug: string, userId?: string) {
|
async findOne(idOrSlug: string, userId?: string) {
|
||||||
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
const content = await this.contentsRepository.findOne(idOrSlug, userId);
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
export enum ContentType {
|
export enum ContentType {
|
||||||
MEME = "meme",
|
MEME = "meme",
|
||||||
GIF = "gif",
|
GIF = "gif",
|
||||||
|
VIDEO = "video",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateContentDto {
|
export class CreateContentDto {
|
||||||
@IsEnum(ContentType)
|
@IsEnum(ContentType)
|
||||||
type!: "meme" | "gif";
|
type!: "meme" | "gif" | "video";
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
|
|||||||
|
|
||||||
export class UploadContentDto {
|
export class UploadContentDto {
|
||||||
@IsEnum(ContentType)
|
@IsEnum(ContentType)
|
||||||
type!: "meme" | "gif";
|
type!: "meme" | "gif" | "video";
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -404,6 +404,15 @@ export class ContentsRepository {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
|
||||||
|
const [updated] = await this.databaseService.db
|
||||||
|
.update(contents)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(contents.id, id))
|
||||||
|
.returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async findBySlug(slug: string) {
|
async findBySlug(slug: string) {
|
||||||
const [result] = await this.databaseService.db
|
const [result] = await this.databaseService.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
67
backend/src/database/database.service.spec.ts
Normal file
67
backend/src/database/database.service.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DatabaseService } from "./database.service";
|
||||||
|
|
||||||
|
jest.mock("pg", () => {
|
||||||
|
const mPool = {
|
||||||
|
connect: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
};
|
||||||
|
return { Pool: jest.fn(() => mPool) };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("drizzle-orm/node-postgres", () => ({
|
||||||
|
drizzle: jest.fn().mockReturnValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("DatabaseService", () => {
|
||||||
|
let service: DatabaseService;
|
||||||
|
let _configService: ConfigService;
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key) => {
|
||||||
|
const config = {
|
||||||
|
POSTGRES_PASSWORD: "p",
|
||||||
|
POSTGRES_USER: "u",
|
||||||
|
POSTGRES_HOST: "h",
|
||||||
|
POSTGRES_PORT: "5432",
|
||||||
|
POSTGRES_DB: "db",
|
||||||
|
NODE_ENV: "development",
|
||||||
|
};
|
||||||
|
return config[key];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DatabaseService,
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<DatabaseService>(DatabaseService);
|
||||||
|
_configService = module.get<ConfigService>(ConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onModuleInit", () => {
|
||||||
|
it("should skip migrations in development", async () => {
|
||||||
|
await service.onModuleInit();
|
||||||
|
expect(mockConfigService.get).toHaveBeenCalledWith("NODE_ENV");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onModuleDestroy", () => {
|
||||||
|
it("should close pool", async () => {
|
||||||
|
const pool = (service as any).pool;
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
expect(pool.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
21
backend/src/database/schemas/comment_likes.ts
Normal file
21
backend/src/database/schemas/comment_likes.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { comments } from "./comments";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const commentLikes = pgTable(
|
||||||
|
"comment_likes",
|
||||||
|
{
|
||||||
|
commentId: uuid("comment_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => comments.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.commentId, t.userId] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
35
backend/src/database/schemas/comments.ts
Normal file
35
backend/src/database/schemas/comments.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { contents } from "./content";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const comments = pgTable(
|
||||||
|
"comments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
contentId: uuid("content_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => contents.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||||
|
parentId: uuid("parent_id").references(() => comments.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
contentIdIdx: index("comments_content_id_idx").on(table.contentId),
|
||||||
|
userIdIdx: index("comments_user_id_idx").on(table.userId),
|
||||||
|
parentIdIdx: index("comments_parent_id_idx").on(table.parentId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CommentInDb = typeof comments.$inferSelect;
|
||||||
|
export type NewCommentInDb = typeof comments.$inferInsert;
|
||||||
@@ -12,7 +12,7 @@ import { categories } from "./categories";
|
|||||||
import { tags } from "./tags";
|
import { tags } from "./tags";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
|
||||||
export const contentType = pgEnum("content_type", ["meme", "gif"]);
|
export const contentType = pgEnum("content_type", ["meme", "gif", "video"]);
|
||||||
|
|
||||||
export const contents = pgTable(
|
export const contents = pgTable(
|
||||||
"contents",
|
"contents",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export * from "./api_keys";
|
export * from "./api_keys";
|
||||||
export * from "./audit_logs";
|
export * from "./audit_logs";
|
||||||
export * from "./categories";
|
export * from "./categories";
|
||||||
|
export * from "./comment_likes";
|
||||||
|
export * from "./comments";
|
||||||
export * from "./content";
|
export * from "./content";
|
||||||
export * from "./favorites";
|
export * from "./favorites";
|
||||||
|
export * from "./messages";
|
||||||
export * from "./pgp";
|
export * from "./pgp";
|
||||||
export * from "./rbac";
|
export * from "./rbac";
|
||||||
export * from "./reports";
|
export * from "./reports";
|
||||||
|
|||||||
66
backend/src/database/schemas/messages.ts
Normal file
66
backend/src/database/schemas/messages.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
index,
|
||||||
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const conversations = pgTable("conversations", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const conversationParticipants = pgTable(
|
||||||
|
"conversation_participants",
|
||||||
|
{
|
||||||
|
conversationId: uuid("conversation_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||||
|
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.conversationId, t.userId] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messages = pgTable(
|
||||||
|
"messages",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
conversationId: uuid("conversation_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||||
|
senderId: uuid("sender_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
readAt: timestamp("read_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
conversationIdIdx: index("messages_conversation_id_idx").on(
|
||||||
|
table.conversationId,
|
||||||
|
),
|
||||||
|
senderIdIdx: index("messages_sender_id_idx").on(table.senderId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationInDb = typeof conversations.$inferSelect;
|
||||||
|
export type NewConversationInDb = typeof conversations.$inferInsert;
|
||||||
|
export type MessageInDb = typeof messages.$inferSelect;
|
||||||
|
export type NewMessageInDb = typeof messages.$inferInsert;
|
||||||
@@ -21,14 +21,19 @@ const getPgpKey = () => process.env.PGP_ENCRYPTION_KEY || "default-pgp-key";
|
|||||||
* withAutomaticPgpDecrypt(users.email);
|
* withAutomaticPgpDecrypt(users.email);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
|
export const pgpEncrypted = customType<{
|
||||||
|
data: string | null;
|
||||||
|
driverData: Buffer | string | null | SQL;
|
||||||
|
}>({
|
||||||
dataType() {
|
dataType() {
|
||||||
return "bytea";
|
return "bytea";
|
||||||
},
|
},
|
||||||
toDriver(value: string): SQL {
|
toDriver(value: string | null): SQL | null {
|
||||||
|
if (value === null) return null;
|
||||||
return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`;
|
return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`;
|
||||||
},
|
},
|
||||||
fromDriver(value: Buffer | string): string {
|
fromDriver(value: Buffer | string | null | any): string | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
return value.toString();
|
return value.toString();
|
||||||
},
|
},
|
||||||
@@ -41,7 +46,9 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
|
|||||||
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
|
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
|
||||||
const originalGetSQL = column.getSQL.bind(column);
|
const originalGetSQL = column.getSQL.bind(column);
|
||||||
column.getSQL = () =>
|
column.getSQL = () =>
|
||||||
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column);
|
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
|
||||||
|
column,
|
||||||
|
);
|
||||||
return column;
|
return column;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,5 +66,7 @@ export function pgpSymDecrypt(
|
|||||||
column: AnyPgColumn,
|
column: AnyPgColumn,
|
||||||
key: string | SQL,
|
key: string | SQL,
|
||||||
): SQL<string> {
|
): SQL<string> {
|
||||||
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>;
|
return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
|
||||||
|
column,
|
||||||
|
) as SQL<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ export const users = pgTable(
|
|||||||
displayName: varchar("display_name", { length: 32 }),
|
displayName: varchar("display_name", { length: 32 }),
|
||||||
|
|
||||||
username: varchar("username", { length: 32 }).notNull().unique(),
|
username: varchar("username", { length: 32 }).notNull().unique(),
|
||||||
passwordHash: varchar("password_hash", { length: 100 }).notNull(),
|
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
||||||
avatarUrl: varchar("avatar_url", { length: 512 }),
|
avatarUrl: varchar("avatar_url", { length: 512 }),
|
||||||
bio: varchar("bio", { length: 255 }),
|
bio: varchar("bio", { length: 255 }),
|
||||||
|
|
||||||
// Sécurité
|
// Sécurité
|
||||||
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
||||||
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
|
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
|
||||||
|
showOnlineStatus: boolean("show_online_status").notNull().default(true),
|
||||||
|
showReadReceipts: boolean("show_read_receipts").notNull().default(true),
|
||||||
|
|
||||||
// RGPD & Conformité
|
// RGPD & Conformité
|
||||||
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées
|
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées
|
||||||
|
|||||||
82
backend/src/favorites/favorites.controller.spec.ts
Normal file
82
backend/src/favorites/favorites.controller.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { FavoritesController } from "./favorites.controller";
|
||||||
|
import { FavoritesService } from "./favorites.service";
|
||||||
|
|
||||||
|
describe("FavoritesController", () => {
|
||||||
|
let controller: FavoritesController;
|
||||||
|
let service: FavoritesService;
|
||||||
|
|
||||||
|
const mockFavoritesService = {
|
||||||
|
addFavorite: jest.fn(),
|
||||||
|
removeFavorite: jest.fn(),
|
||||||
|
getUserFavorites: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [FavoritesController],
|
||||||
|
providers: [{ provide: FavoritesService, useValue: mockFavoritesService }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<FavoritesController>(FavoritesController);
|
||||||
|
service = module.get<FavoritesService>(FavoritesService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add", () => {
|
||||||
|
it("should call service.addFavorite", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.add(req, "content-1");
|
||||||
|
expect(service.addFavorite).toHaveBeenCalledWith("user-uuid", "content-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should call service.removeFavorite", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.remove(req, "content-1");
|
||||||
|
expect(service.removeFavorite).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"content-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list", () => {
|
||||||
|
it("should call service.getUserFavorites", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.list(req, 10, 0);
|
||||||
|
expect(service.getUserFavorites).toHaveBeenCalledWith("user-uuid", 10, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import { FavoritesRepository } from "./favorites.repository";
|
||||||
|
|
||||||
|
describe("FavoritesRepository", () => {
|
||||||
|
let repository: FavoritesRepository;
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
from: jest.fn().mockReturnThis(),
|
||||||
|
innerJoin: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
limit: jest.fn().mockReturnThis(),
|
||||||
|
offset: jest.fn().mockReturnThis(),
|
||||||
|
insert: jest.fn().mockReturnThis(),
|
||||||
|
values: jest.fn().mockReturnThis(),
|
||||||
|
delete: jest.fn().mockReturnThis(),
|
||||||
|
returning: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapWithThen = (obj: unknown) => {
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
|
Object.defineProperty(obj, "then", {
|
||||||
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
|
const result = (this as Record<string, unknown>).execute();
|
||||||
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
wrapWithThen(mockDb);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FavoritesRepository,
|
||||||
|
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repository = module.get<FavoritesRepository>(FavoritesRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find content by id", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
const result = await repository.findContentById("1");
|
||||||
|
expect(result.id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add favorite", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([
|
||||||
|
{ userId: "u", contentId: "c" },
|
||||||
|
]);
|
||||||
|
await repository.add("u", "c");
|
||||||
|
expect(mockDb.insert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove favorite", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
await repository.remove("u", "c");
|
||||||
|
expect(mockDb.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find by user id", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ content: { id: "c1" } }]);
|
||||||
|
const result = await repository.findByUserId("u1", 10, 0);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("c1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { DatabaseService } from "./database/database.service";
|
import { DatabaseService } from "./database/database.service";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
@@ -9,6 +10,10 @@ describe("HealthController", () => {
|
|||||||
execute: jest.fn().mockResolvedValue([]),
|
execute: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockCacheManager = {
|
||||||
|
set: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
@@ -19,24 +24,42 @@ describe("HealthController", () => {
|
|||||||
db: mockDb,
|
db: mockDb,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CACHE_MANAGER,
|
||||||
|
useValue: mockCacheManager,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<HealthController>(HealthController);
|
controller = module.get<HealthController>(HealthController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return ok if database is connected", async () => {
|
it("should return ok if database and redis are connected", async () => {
|
||||||
mockDb.execute.mockResolvedValue([]);
|
mockDb.execute.mockResolvedValue([]);
|
||||||
|
mockCacheManager.set.mockResolvedValue(undefined);
|
||||||
const result = await controller.check();
|
const result = await controller.check();
|
||||||
expect(result.status).toBe("ok");
|
expect(result.status).toBe("ok");
|
||||||
expect(result.database).toBe("connected");
|
expect(result.database).toBe("connected");
|
||||||
|
expect(result.redis).toBe("connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error if database is disconnected", async () => {
|
it("should return error if database is disconnected", async () => {
|
||||||
mockDb.execute.mockRejectedValue(new Error("DB Error"));
|
mockDb.execute.mockRejectedValue(new Error("DB Error"));
|
||||||
|
mockCacheManager.set.mockResolvedValue(undefined);
|
||||||
const result = await controller.check();
|
const result = await controller.check();
|
||||||
expect(result.status).toBe("error");
|
expect(result.status).toBe("error");
|
||||||
expect(result.database).toBe("disconnected");
|
expect(result.database).toBe("disconnected");
|
||||||
expect(result.message).toBe("DB Error");
|
expect(result.databaseError).toBe("DB Error");
|
||||||
|
expect(result.redis).toBe("connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return error if redis is disconnected", async () => {
|
||||||
|
mockDb.execute.mockResolvedValue([]);
|
||||||
|
mockCacheManager.set.mockRejectedValue(new Error("Redis Error"));
|
||||||
|
const result = await controller.check();
|
||||||
|
expect(result.status).toBe("error");
|
||||||
|
expect(result.database).toBe("connected");
|
||||||
|
expect(result.redis).toBe("disconnected");
|
||||||
|
expect(result.redisError).toBe("Redis Error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { Controller, Get, Inject } from "@nestjs/common";
|
||||||
|
import type { Cache } from "cache-manager";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { DatabaseService } from "./database/database.service";
|
import { DatabaseService } from "./database/database.service";
|
||||||
|
|
||||||
@Controller("health")
|
@Controller("health")
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private readonly databaseService: DatabaseService) {}
|
constructor(
|
||||||
|
private readonly databaseService: DatabaseService,
|
||||||
|
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async check() {
|
async check() {
|
||||||
|
const health: any = {
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check database connection
|
// Check database connection
|
||||||
await this.databaseService.db.execute(sql`SELECT 1`);
|
await this.databaseService.db.execute(sql`SELECT 1`);
|
||||||
return {
|
health.database = "connected";
|
||||||
status: "ok",
|
|
||||||
database: "connected",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
health.status = "error";
|
||||||
status: "error",
|
health.database = "disconnected";
|
||||||
database: "disconnected",
|
health.databaseError = error.message;
|
||||||
message: error.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check Redis connection via cache-manager
|
||||||
|
// We try to set a temporary key to verify the connection
|
||||||
|
await this.cacheManager.set("health-check", "ok", 1000);
|
||||||
|
health.redis = "connected";
|
||||||
|
} catch (error) {
|
||||||
|
health.status = "error";
|
||||||
|
health.redis = "disconnected";
|
||||||
|
health.redisError = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return health;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ describe("MediaController", () => {
|
|||||||
expect(stream.pipe).toHaveBeenCalledWith(res);
|
expect(stream.pipe).toHaveBeenCalledWith(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if path is missing", async () => {
|
||||||
|
const res = {} as unknown as Response;
|
||||||
|
await expect(controller.getFile("", res)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException if file is not found", async () => {
|
it("should throw NotFoundException if file is not found", async () => {
|
||||||
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
mockS3Service.getFileInfo.mockRejectedValue(new Error("Not found"));
|
||||||
const res = {} as unknown as Response;
|
const res = {} as unknown as Response;
|
||||||
|
|||||||
@@ -1,21 +1,36 @@
|
|||||||
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type { BucketItemStat } from "minio";
|
import type { BucketItemStat } from "minio";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
|
|
||||||
@Controller("media")
|
@Controller("media")
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
|
private readonly logger = new Logger(MediaController.name);
|
||||||
|
|
||||||
constructor(private readonly s3Service: S3Service) {}
|
constructor(private readonly s3Service: S3Service) {}
|
||||||
|
|
||||||
@Get("*key")
|
@Get()
|
||||||
async getFile(@Param("key") key: string, @Res() res: Response) {
|
async getFile(@Query("path") path: string, @Res() res: Response) {
|
||||||
try {
|
if (!path) {
|
||||||
const stats = (await this.s3Service.getFileInfo(key)) as BucketItemStat;
|
this.logger.warn("Tentative d'accès à un média sans paramètre 'path'");
|
||||||
const stream = await this.s3Service.getFile(key);
|
throw new NotFoundException("Paramètre 'path' manquant");
|
||||||
|
}
|
||||||
|
|
||||||
const contentType =
|
try {
|
||||||
|
this.logger.log(`Récupération du fichier : ${path}`);
|
||||||
|
const stats = (await this.s3Service.getFileInfo(path)) as BucketItemStat;
|
||||||
|
const stream = await this.s3Service.getFile(path);
|
||||||
|
|
||||||
|
const contentType: string =
|
||||||
stats.metaData?.["content-type"] ||
|
stats.metaData?.["content-type"] ||
|
||||||
stats.metadata?.["content-type"] ||
|
stats.metaData?.["Content-Type"] ||
|
||||||
"application/octet-stream";
|
"application/octet-stream";
|
||||||
|
|
||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
@@ -23,7 +38,10 @@ export class MediaController {
|
|||||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Erreur lors de la récupération du fichier ${path} : ${error.message}`,
|
||||||
|
);
|
||||||
throw new NotFoundException("Fichier non trouvé");
|
throw new NotFoundException("Fichier non trouvé");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("MediaService", () => {
|
|||||||
toFormat: jest.fn().mockReturnThis(),
|
toFormat: jest.fn().mockReturnThis(),
|
||||||
videoCodec: jest.fn().mockReturnThis(),
|
videoCodec: jest.fn().mockReturnThis(),
|
||||||
audioCodec: jest.fn().mockReturnThis(),
|
audioCodec: jest.fn().mockReturnThis(),
|
||||||
outputOptions: jest.fn().mockReturnThis(),
|
addOutputOptions: jest.fn().mockReturnThis(),
|
||||||
on: jest.fn().mockImplementation(function (event, cb) {
|
on: jest.fn().mockImplementation(function (event, cb) {
|
||||||
if (event === "end") setTimeout(cb, 0);
|
if (event === "end") setTimeout(cb, 0);
|
||||||
return this;
|
return this;
|
||||||
@@ -96,4 +96,37 @@ describe("MediaService", () => {
|
|||||||
expect(result.buffer).toEqual(Buffer.from("processed-video"));
|
expect(result.buffer).toEqual(Buffer.from("processed-video"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scanFile", () => {
|
||||||
|
it("should return false if clamav not initialized", async () => {
|
||||||
|
const result = await service.scanFile(Buffer.from(""), "test.txt");
|
||||||
|
expect(result.isInfected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle virus detection", async () => {
|
||||||
|
// Mock private property to simulate initialized clamscan
|
||||||
|
(service as any).isClamAvInitialized = true;
|
||||||
|
(service as any).clamscan = {
|
||||||
|
scanStream: jest.fn().mockResolvedValue({
|
||||||
|
isInfected: true,
|
||||||
|
viruses: ["Eicar-Test-Signature"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.scanFile(Buffer.from(""), "test.txt");
|
||||||
|
expect(result.isInfected).toBe(true);
|
||||||
|
expect(result.virusName).toBe("Eicar-Test-Signature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle scan error", async () => {
|
||||||
|
(service as any).isClamAvInitialized = true;
|
||||||
|
(service as any).clamscan = {
|
||||||
|
scanStream: jest.fn().mockRejectedValue(new Error("Scan failed")),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.scanFile(Buffer.from(""), "test.txt"),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
|||||||
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
||||||
|
|
||||||
canHandle(mimeType: string): boolean {
|
canHandle(mimeType: string): boolean {
|
||||||
return mimeType.startsWith("video/");
|
return mimeType.startsWith("video/") || mimeType === "image/gif";
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(
|
async process(
|
||||||
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
|||||||
.toFormat("webm")
|
.toFormat("webm")
|
||||||
.videoCodec("libvpx-vp9")
|
.videoCodec("libvpx-vp9")
|
||||||
.audioCodec("libopus")
|
.audioCodec("libopus")
|
||||||
.outputOptions("-crf 30", "-b:v 0");
|
.addOutputOptions("-crf", "30", "-b:v", "0");
|
||||||
} else {
|
} else {
|
||||||
command = command
|
command = command
|
||||||
.toFormat("mp4")
|
.toFormat("mp4")
|
||||||
.videoCodec("libaom-av1")
|
.videoCodec("libaom-av1")
|
||||||
.audioCodec("libopus")
|
.audioCodec("libopus")
|
||||||
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
|
.addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
|
||||||
}
|
}
|
||||||
|
|
||||||
command
|
command
|
||||||
|
|||||||
11
backend/src/messages/dto/create-message.dto.ts
Normal file
11
backend/src/messages/dto/create-message.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateMessageDto {
|
||||||
|
@IsUUID()
|
||||||
|
recipientId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(2000)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
53
backend/src/messages/messages.controller.ts
Normal file
53
backend/src/messages/messages.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { CreateMessageDto } from "./dto/create-message.dto";
|
||||||
|
import { MessagesService } from "./messages.service";
|
||||||
|
|
||||||
|
@Controller("messages")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class MessagesController {
|
||||||
|
constructor(private readonly messagesService: MessagesService) {}
|
||||||
|
|
||||||
|
@Get("conversations")
|
||||||
|
getConversations(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.messagesService.getConversations(req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("unread-count")
|
||||||
|
getUnreadCount(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.messagesService.getUnreadCount(req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("conversations/with/:userId")
|
||||||
|
getConversationWithUser(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param("userId") targetUserId: string,
|
||||||
|
) {
|
||||||
|
return this.messagesService.getConversationWithUser(
|
||||||
|
req.user.sub,
|
||||||
|
targetUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("conversations/:id")
|
||||||
|
getMessages(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param("id") conversationId: string,
|
||||||
|
) {
|
||||||
|
return this.messagesService.getMessages(req.user.sub, conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
sendMessage(@Req() req: AuthenticatedRequest, @Body() dto: CreateMessageDto) {
|
||||||
|
return this.messagesService.sendMessage(req.user.sub, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/messages/messages.module.ts
Normal file
15
backend/src/messages/messages.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { RealtimeModule } from "../realtime/realtime.module";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
import { MessagesController } from "./messages.controller";
|
||||||
|
import { MessagesService } from "./messages.service";
|
||||||
|
import { MessagesRepository } from "./repositories/messages.repository";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, RealtimeModule, forwardRef(() => UsersModule)],
|
||||||
|
controllers: [MessagesController],
|
||||||
|
providers: [MessagesService, MessagesRepository],
|
||||||
|
exports: [MessagesService],
|
||||||
|
})
|
||||||
|
export class MessagesModule {}
|
||||||
105
backend/src/messages/messages.service.spec.ts
Normal file
105
backend/src/messages/messages.service.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import { MessagesService } from "./messages.service";
|
||||||
|
import { MessagesRepository } from "./repositories/messages.repository";
|
||||||
|
|
||||||
|
describe("MessagesService", () => {
|
||||||
|
let service: MessagesService;
|
||||||
|
let _repository: MessagesRepository;
|
||||||
|
let _eventsGateway: EventsGateway;
|
||||||
|
|
||||||
|
const mockMessagesRepository = {
|
||||||
|
findConversationBetweenUsers: jest.fn(),
|
||||||
|
createConversation: jest.fn(),
|
||||||
|
addParticipant: jest.fn(),
|
||||||
|
createMessage: jest.fn(),
|
||||||
|
findAllConversations: jest.fn(),
|
||||||
|
isParticipant: jest.fn(),
|
||||||
|
getParticipants: jest.fn(),
|
||||||
|
findMessagesByConversationId: jest.fn(),
|
||||||
|
markAsRead: jest.fn(),
|
||||||
|
countUnreadMessages: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEventsGateway = {
|
||||||
|
sendToUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MessagesService,
|
||||||
|
{ provide: MessagesRepository, useValue: mockMessagesRepository },
|
||||||
|
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||||
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MessagesService>(MessagesService);
|
||||||
|
_repository = module.get<MessagesRepository>(MessagesRepository);
|
||||||
|
_eventsGateway = module.get<EventsGateway>(EventsGateway);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendMessage", () => {
|
||||||
|
it("should send message to existing conversation", async () => {
|
||||||
|
const senderId = "s1";
|
||||||
|
const dto = { recipientId: "r1", text: "hello" };
|
||||||
|
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue({
|
||||||
|
id: "conv1",
|
||||||
|
});
|
||||||
|
mockMessagesRepository.createMessage.mockResolvedValue({
|
||||||
|
id: "m1",
|
||||||
|
text: "hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.sendMessage(senderId, dto);
|
||||||
|
|
||||||
|
expect(result.id).toBe("m1");
|
||||||
|
expect(mockEventsGateway.sendToUser).toHaveBeenCalledWith(
|
||||||
|
"r1",
|
||||||
|
"new_message",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create new conversation if not exists", async () => {
|
||||||
|
const senderId = "s1";
|
||||||
|
const dto = { recipientId: "r1", text: "hello" };
|
||||||
|
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue(null);
|
||||||
|
mockMessagesRepository.createConversation.mockResolvedValue({
|
||||||
|
id: "new_conv",
|
||||||
|
});
|
||||||
|
mockMessagesRepository.createMessage.mockResolvedValue({ id: "m1" });
|
||||||
|
|
||||||
|
await service.sendMessage(senderId, dto);
|
||||||
|
|
||||||
|
expect(mockMessagesRepository.createConversation).toHaveBeenCalled();
|
||||||
|
expect(mockMessagesRepository.addParticipant).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMessages", () => {
|
||||||
|
it("should return messages if user is participant", async () => {
|
||||||
|
mockMessagesRepository.isParticipant.mockResolvedValue(true);
|
||||||
|
mockMessagesRepository.findMessagesByConversationId.mockResolvedValue([
|
||||||
|
{ id: "m1" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getMessages("u1", "conv1");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException if user is not participant", async () => {
|
||||||
|
mockMessagesRepository.isParticipant.mockResolvedValue(false);
|
||||||
|
await expect(service.getMessages("u1", "conv1")).rejects.toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
125
backend/src/messages/messages.service.ts
Normal file
125
backend/src/messages/messages.service.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import type { CreateMessageDto } from "./dto/create-message.dto";
|
||||||
|
import { MessagesRepository } from "./repositories/messages.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagesService {
|
||||||
|
constructor(
|
||||||
|
private readonly messagesRepository: MessagesRepository,
|
||||||
|
private readonly eventsGateway: EventsGateway,
|
||||||
|
@Inject(forwardRef(() => UsersService))
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendMessage(senderId: string, dto: CreateMessageDto) {
|
||||||
|
let conversation = await this.messagesRepository.findConversationBetweenUsers(
|
||||||
|
senderId,
|
||||||
|
dto.recipientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
const newConv = await this.messagesRepository.createConversation();
|
||||||
|
await this.messagesRepository.addParticipant(newConv.id, senderId);
|
||||||
|
await this.messagesRepository.addParticipant(newConv.id, dto.recipientId);
|
||||||
|
conversation = newConv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await this.messagesRepository.createMessage({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
senderId,
|
||||||
|
text: dto.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify recipient via WebSocket
|
||||||
|
this.eventsGateway.sendToUser(dto.recipientId, "new_message", {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversations(userId: string) {
|
||||||
|
return this.messagesRepository.findAllConversations(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: string) {
|
||||||
|
return this.messagesRepository.countUnreadMessages(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversationWithUser(userId: string, targetUserId: string) {
|
||||||
|
return this.messagesRepository.findConversationBetweenUsers(
|
||||||
|
userId,
|
||||||
|
targetUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(userId: string, conversationId: string) {
|
||||||
|
const isParticipant = await this.messagesRepository.isParticipant(
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException("You are not part of this conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les préférences de l'utilisateur actuel
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
|
||||||
|
// Marquer comme lus seulement si l'utilisateur l'autorise
|
||||||
|
if (user?.showReadReceipts) {
|
||||||
|
await this.messagesRepository.markAsRead(conversationId, userId);
|
||||||
|
|
||||||
|
// Notifier l'expéditeur que les messages ont été lus
|
||||||
|
const participants =
|
||||||
|
await this.messagesRepository.getParticipants(conversationId);
|
||||||
|
const otherParticipant = participants.find((p) => p.userId !== userId);
|
||||||
|
if (otherParticipant) {
|
||||||
|
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
|
||||||
|
conversationId,
|
||||||
|
readerId: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.messagesRepository.findMessagesByConversationId(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(userId: string, conversationId: string) {
|
||||||
|
const isParticipant = await this.messagesRepository.isParticipant(
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException("You are not part of this conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
if (!user?.showReadReceipts) return;
|
||||||
|
|
||||||
|
const result = await this.messagesRepository.markAsRead(
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notifier l'autre participant
|
||||||
|
const participants =
|
||||||
|
await this.messagesRepository.getParticipants(conversationId);
|
||||||
|
const otherParticipant = participants.find((p) => p.userId !== userId);
|
||||||
|
if (otherParticipant) {
|
||||||
|
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
|
||||||
|
conversationId,
|
||||||
|
readerId: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
backend/src/messages/repositories/messages.repository.ts
Normal file
167
backend/src/messages/repositories/messages.repository.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import {
|
||||||
|
conversationParticipants,
|
||||||
|
conversations,
|
||||||
|
messages,
|
||||||
|
users,
|
||||||
|
} from "../../database/schemas";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagesRepository {
|
||||||
|
constructor(private readonly databaseService: DatabaseService) {}
|
||||||
|
|
||||||
|
async findConversationBetweenUsers(userId1: string, userId2: string) {
|
||||||
|
const results = await this.databaseService.db
|
||||||
|
.select({ id: conversations.id })
|
||||||
|
.from(conversations)
|
||||||
|
.innerJoin(
|
||||||
|
conversationParticipants,
|
||||||
|
eq(conversations.id, conversationParticipants.conversationId),
|
||||||
|
)
|
||||||
|
.where(inArray(conversationParticipants.userId, [userId1, userId2]))
|
||||||
|
.groupBy(conversations.id)
|
||||||
|
.having(sql`count(${conversations.id}) = 2`);
|
||||||
|
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConversation() {
|
||||||
|
const [conv] = await this.databaseService.db
|
||||||
|
.insert(conversations)
|
||||||
|
.values({})
|
||||||
|
.returning();
|
||||||
|
return conv;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addParticipant(conversationId: string, userId: string) {
|
||||||
|
await this.databaseService.db
|
||||||
|
.insert(conversationParticipants)
|
||||||
|
.values({ conversationId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMessage(data: {
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
const [msg] = await this.databaseService.db
|
||||||
|
.insert(messages)
|
||||||
|
.values(data)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Update conversation updatedAt
|
||||||
|
await this.databaseService.db
|
||||||
|
.update(conversations)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(conversations.id, data.conversationId));
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllConversations(userId: string) {
|
||||||
|
// Sous-requête pour trouver les IDs des conversations de l'utilisateur
|
||||||
|
const userConvs = this.databaseService.db
|
||||||
|
.select({ id: conversationParticipants.conversationId })
|
||||||
|
.from(conversationParticipants)
|
||||||
|
.where(eq(conversationParticipants.userId, userId));
|
||||||
|
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({
|
||||||
|
id: conversations.id,
|
||||||
|
updatedAt: conversations.updatedAt,
|
||||||
|
lastMessage: {
|
||||||
|
text: messages.text,
|
||||||
|
createdAt: messages.createdAt,
|
||||||
|
},
|
||||||
|
recipient: {
|
||||||
|
uuid: users.uuid,
|
||||||
|
username: users.username,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(conversations)
|
||||||
|
.innerJoin(
|
||||||
|
conversationParticipants,
|
||||||
|
eq(conversations.id, conversationParticipants.conversationId),
|
||||||
|
)
|
||||||
|
.innerJoin(users, eq(conversationParticipants.userId, users.uuid))
|
||||||
|
.leftJoin(messages, eq(conversations.id, messages.conversationId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(conversations.id, userConvs),
|
||||||
|
eq(conversationParticipants.userId, users.uuid),
|
||||||
|
sql`${users.uuid} != ${userId}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(conversations.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMessagesByConversationId(conversationId: string, limit = 50) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({
|
||||||
|
id: messages.id,
|
||||||
|
text: messages.text,
|
||||||
|
createdAt: messages.createdAt,
|
||||||
|
senderId: messages.senderId,
|
||||||
|
readAt: messages.readAt,
|
||||||
|
})
|
||||||
|
.from(messages)
|
||||||
|
.where(eq(messages.conversationId, conversationId))
|
||||||
|
.orderBy(desc(messages.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isParticipant(conversationId: string, userId: string) {
|
||||||
|
const [participant] = await this.databaseService.db
|
||||||
|
.select()
|
||||||
|
.from(conversationParticipants)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(conversationParticipants.conversationId, conversationId),
|
||||||
|
eq(conversationParticipants.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return !!participant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParticipants(conversationId: string) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({ userId: conversationParticipants.userId })
|
||||||
|
.from(conversationParticipants)
|
||||||
|
.where(eq(conversationParticipants.conversationId, conversationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(conversationId: string, userId: string) {
|
||||||
|
await this.databaseService.db
|
||||||
|
.update(messages)
|
||||||
|
.set({ readAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(messages.conversationId, conversationId),
|
||||||
|
sql`${messages.senderId} != ${userId}`,
|
||||||
|
sql`${messages.readAt} IS NULL`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countUnreadMessages(userId: string) {
|
||||||
|
const result = await this.databaseService.db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(messages)
|
||||||
|
.innerJoin(
|
||||||
|
conversationParticipants,
|
||||||
|
eq(messages.conversationId, conversationParticipants.conversationId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(conversationParticipants.userId, userId),
|
||||||
|
sql`${messages.senderId} != ${userId}`,
|
||||||
|
sql`${messages.readAt} IS NULL`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Number(result[0]?.count || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/src/realtime/events.gateway.spec.ts
Normal file
60
backend/src/realtime/events.gateway.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { JwtService } from "../crypto/services/jwt.service";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import { EventsGateway } from "./events.gateway";
|
||||||
|
|
||||||
|
describe("EventsGateway", () => {
|
||||||
|
let gateway: EventsGateway;
|
||||||
|
let _jwtService: JwtService;
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
verifyJwt: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
EventsGateway,
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
gateway = module.get<EventsGateway>(EventsGateway);
|
||||||
|
_jwtService = module.get<JwtService>(JwtService);
|
||||||
|
gateway.server = {
|
||||||
|
to: jest.fn().mockReturnThis(),
|
||||||
|
emit: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(gateway).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendToUser", () => {
|
||||||
|
it("should emit event to user room", () => {
|
||||||
|
const userId = "user123";
|
||||||
|
const event = "test_event";
|
||||||
|
const data = { foo: "bar" };
|
||||||
|
|
||||||
|
gateway.sendToUser(userId, event, data);
|
||||||
|
|
||||||
|
expect(gateway.server.to).toHaveBeenCalledWith(`user:${userId}`);
|
||||||
|
expect(gateway.server.to(`user:${userId}`).emit).toHaveBeenCalledWith(
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
220
backend/src/realtime/events.gateway.ts
Normal file
220
backend/src/realtime/events.gateway.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { forwardRef, Inject, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
OnGatewayInit,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { getIronSession } from "iron-session";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
import { getSessionOptions, SessionData } from "../auth/session.config";
|
||||||
|
import { JwtService } from "../crypto/services/jwt.service";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
transports: ["websocket"],
|
||||||
|
cors: {
|
||||||
|
origin: (
|
||||||
|
origin: string,
|
||||||
|
callback: (err: Error | null, allow?: boolean) => void,
|
||||||
|
) => {
|
||||||
|
// Autoriser si pas d'origine (ex: app mobile ou serveur à serveur)
|
||||||
|
// ou si on est en développement local
|
||||||
|
if (
|
||||||
|
!origin ||
|
||||||
|
origin.includes("localhost") ||
|
||||||
|
origin.includes("127.0.0.1")
|
||||||
|
) {
|
||||||
|
callback(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// En production, on peut restreindre via une variable d'environnement
|
||||||
|
const domainName = process.env.CORS_DOMAIN_NAME;
|
||||||
|
if (!domainName || domainName === "*") {
|
||||||
|
callback(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedOrigins = domainName.split(",").map((o) => o.trim());
|
||||||
|
if (allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error("Not allowed by CORS"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class EventsGateway
|
||||||
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||||
|
{
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(EventsGateway.name);
|
||||||
|
private readonly onlineUsers = new Map<string, Set<string>>(); // userId -> Set of socketIds
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject(forwardRef(() => UsersService))
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
afterInit(_server: Server) {
|
||||||
|
this.logger.log("WebSocket Gateway initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConnection(client: Socket) {
|
||||||
|
try {
|
||||||
|
// Simuler un objet Request/Response pour iron-session
|
||||||
|
const req: any = {
|
||||||
|
headers: client.handshake.headers,
|
||||||
|
};
|
||||||
|
const res: any = {
|
||||||
|
setHeader: () => {},
|
||||||
|
getHeader: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = await getIronSession<SessionData>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session.accessToken) {
|
||||||
|
this.logger.warn(`Client ${client.id} unauthorized connection`);
|
||||||
|
// Permettre les connexions anonymes pour voir les commentaires en temps réel ?
|
||||||
|
// Pour l'instant on déconnecte car le système actuel semble exiger l'auth
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await this.jwtService.verifyJwt(session.accessToken);
|
||||||
|
if (!payload.sub) {
|
||||||
|
throw new Error("Invalid token payload: missing sub");
|
||||||
|
}
|
||||||
|
|
||||||
|
client.data.user = payload;
|
||||||
|
|
||||||
|
// Rejoindre une room personnelle pour les notifications
|
||||||
|
client.join(`user:${payload.sub}`);
|
||||||
|
|
||||||
|
// Gérer le statut en ligne
|
||||||
|
const userId = payload.sub as string;
|
||||||
|
|
||||||
|
if (!this.onlineUsers.has(userId)) {
|
||||||
|
this.onlineUsers.set(userId, new Set());
|
||||||
|
|
||||||
|
// Vérifier les préférences de l'utilisateur
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
if (user?.showOnlineStatus) {
|
||||||
|
this.broadcastStatus(userId, "online");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.onlineUsers.get(userId)?.add(client.id);
|
||||||
|
|
||||||
|
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Connection error for client ${client.id}: ${error}`);
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDisconnect(client: Socket) {
|
||||||
|
const userId = client.data.user?.sub;
|
||||||
|
if (userId && this.onlineUsers.has(userId)) {
|
||||||
|
const sockets = this.onlineUsers.get(userId);
|
||||||
|
sockets?.delete(client.id);
|
||||||
|
if (sockets?.size === 0) {
|
||||||
|
this.onlineUsers.delete(userId);
|
||||||
|
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
if (user?.showOnlineStatus) {
|
||||||
|
this.broadcastStatus(userId, "offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`Client disconnected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastStatus(userId: string, status: "online" | "offline") {
|
||||||
|
this.server.emit("user_status", { userId, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
isUserOnline(userId: string): boolean {
|
||||||
|
return this.onlineUsers.has(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage("join_content")
|
||||||
|
handleJoinContent(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() contentId: string,
|
||||||
|
) {
|
||||||
|
client.join(`content:${contentId}`);
|
||||||
|
this.logger.log(`Client ${client.id} joined content room: ${contentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage("leave_content")
|
||||||
|
handleLeaveContent(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() contentId: string,
|
||||||
|
) {
|
||||||
|
client.leave(`content:${contentId}`);
|
||||||
|
this.logger.log(`Client ${client.id} left content room: ${contentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage("typing")
|
||||||
|
async handleTyping(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { recipientId: string; isTyping: boolean },
|
||||||
|
) {
|
||||||
|
const userId = client.data.user?.sub;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing"
|
||||||
|
// ou si on considère que typing est une interaction directe qui outrepasse le statut.
|
||||||
|
// Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat.
|
||||||
|
// Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier.
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
if (!user?.showOnlineStatus) return;
|
||||||
|
|
||||||
|
this.server.to(`user:${data.recipientId}`).emit("user_typing", {
|
||||||
|
userId,
|
||||||
|
isTyping: data.isTyping,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage("check_status")
|
||||||
|
async handleCheckStatus(
|
||||||
|
@ConnectedSocket() _client: Socket,
|
||||||
|
@MessageBody() userId: string,
|
||||||
|
) {
|
||||||
|
const isOnline = this.onlineUsers.has(userId);
|
||||||
|
if (!isOnline) return { userId, status: "offline" };
|
||||||
|
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
if (!user?.showOnlineStatus) return { userId, status: "offline" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
status: "online",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
|
||||||
|
sendToUser(userId: string, event: string, data: any) {
|
||||||
|
this.server.to(`user:${userId}`).emit(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToContent(contentId: string, event: string, data: any) {
|
||||||
|
this.server.to(`content:${contentId}`).emit(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/realtime/realtime.module.ts
Normal file
12
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
import { EventsGateway } from "./events.gateway";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CryptoModule, ConfigModule, forwardRef(() => UsersModule)],
|
||||||
|
providers: [EventsGateway],
|
||||||
|
exports: [EventsGateway],
|
||||||
|
})
|
||||||
|
export class RealtimeModule {}
|
||||||
82
backend/src/reports/reports.controller.spec.ts
Normal file
82
backend/src/reports/reports.controller.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||||
|
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { ReportsController } from "./reports.controller";
|
||||||
|
import { ReportsService } from "./reports.service";
|
||||||
|
|
||||||
|
describe("ReportsController", () => {
|
||||||
|
let controller: ReportsController;
|
||||||
|
let service: ReportsService;
|
||||||
|
|
||||||
|
const mockReportsService = {
|
||||||
|
create: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
updateStatus: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [ReportsController],
|
||||||
|
providers: [{ provide: ReportsService, useValue: mockReportsService }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<ReportsController>(ReportsController);
|
||||||
|
service = module.get<ReportsService>(ReportsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should call service.create", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const dto = { contentId: "1", reason: "spam" };
|
||||||
|
await controller.create(req, dto as any);
|
||||||
|
expect(service.create).toHaveBeenCalledWith("user-uuid", dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should call service.findAll", async () => {
|
||||||
|
await controller.findAll(10, 0);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith(10, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateStatus", () => {
|
||||||
|
it("should call service.updateStatus", async () => {
|
||||||
|
const dto = { status: "resolved" as any };
|
||||||
|
await controller.updateStatus("1", dto);
|
||||||
|
expect(service.updateStatus).toHaveBeenCalledWith("1", "resolved");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
backend/src/reports/repositories/reports.repository.spec.ts
Normal file
73
backend/src/reports/repositories/reports.repository.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import { ReportsRepository } from "./reports.repository";
|
||||||
|
|
||||||
|
describe("ReportsRepository", () => {
|
||||||
|
let repository: ReportsRepository;
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
from: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
limit: jest.fn().mockReturnThis(),
|
||||||
|
offset: jest.fn().mockReturnThis(),
|
||||||
|
insert: jest.fn().mockReturnThis(),
|
||||||
|
values: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
delete: jest.fn().mockReturnThis(),
|
||||||
|
returning: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapWithThen = (obj: unknown) => {
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Necessary to mock Drizzle's awaitable query builder
|
||||||
|
Object.defineProperty(obj, "then", {
|
||||||
|
value: function (onFulfilled: (arg0: unknown) => void) {
|
||||||
|
const result = (this as Record<string, unknown>).execute();
|
||||||
|
return Promise.resolve(result).then(onFulfilled);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
wrapWithThen(mockDb);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ReportsRepository,
|
||||||
|
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repository = module.get<ReportsRepository>(ReportsRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create report", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
const result = await repository.create({ reporterId: "u", reason: "spam" });
|
||||||
|
expect(result.id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find all", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
const result = await repository.findAll(10, 0);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update status", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: "1", status: "resolved" },
|
||||||
|
]);
|
||||||
|
const result = await repository.updateStatus("1", "resolved");
|
||||||
|
expect(result[0].status).toBe("resolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should purge obsolete", async () => {
|
||||||
|
(mockDb.execute as jest.Mock).mockResolvedValue([{ id: "1" }]);
|
||||||
|
await repository.purgeObsolete(new Date());
|
||||||
|
expect(mockDb.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,6 @@ jest.mock("minio");
|
|||||||
describe("S3Service", () => {
|
describe("S3Service", () => {
|
||||||
let service: S3Service;
|
let service: S3Service;
|
||||||
let configService: ConfigService;
|
let configService: ConfigService;
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Fine for testing purposes
|
|
||||||
let minioClient: any;
|
let minioClient: any;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -192,7 +191,7 @@ describe("S3Service", () => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const url = service.getPublicUrl("test.webp");
|
const url = service.getPublicUrl("test.webp");
|
||||||
expect(url).toBe("https://api.test.com/media/test.webp");
|
expect(url).toBe("https://api.test.com/media?path=test.webp");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
it("should use DOMAIN_NAME and PORT for localhost", () => {
|
||||||
@@ -205,7 +204,7 @@ describe("S3Service", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const url = service.getPublicUrl("test.webp");
|
const url = service.getPublicUrl("test.webp");
|
||||||
expect(url).toBe("http://localhost:3000/media/test.webp");
|
expect(url).toBe("http://localhost:3000/media?path=test.webp");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use api.DOMAIN_NAME for production", () => {
|
it("should use api.DOMAIN_NAME for production", () => {
|
||||||
@@ -217,7 +216,7 @@ describe("S3Service", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const url = service.getPublicUrl("test.webp");
|
const url = service.getPublicUrl("test.webp");
|
||||||
expect(url).toBe("https://api.memegoat.fr/media/test.webp");
|
expect(url).toBe("https://api.memegoat.fr/media?path=test.webp");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,6 +173,6 @@ export class S3Service implements OnModuleInit, IStorageService {
|
|||||||
baseUrl = `https://api.${domain}`;
|
baseUrl = `https://api.${domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseUrl}/media/${storageKey}`;
|
return `${baseUrl}/media?path=${storageKey}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
backend/src/tags/tags.controller.spec.ts
Normal file
69
backend/src/tags/tags.controller.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { TagsController } from "./tags.controller";
|
||||||
|
import { TagsService } from "./tags.service";
|
||||||
|
|
||||||
|
describe("TagsController", () => {
|
||||||
|
let controller: TagsController;
|
||||||
|
let service: TagsService;
|
||||||
|
|
||||||
|
const mockTagsService = {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCacheManager = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [TagsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: TagsService, useValue: mockTagsService },
|
||||||
|
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<TagsController>(TagsController);
|
||||||
|
service = module.get<TagsService>(TagsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should call service.findAll", async () => {
|
||||||
|
await controller.findAll(10, 0, "test", "popular");
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith({
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
query: "test",
|
||||||
|
sortBy: "popular",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -14,4 +14,20 @@ export class UpdateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
role?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showOnlineStatus?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showReadReceipts?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
150
backend/src/users/repositories/users.repository.spec.ts
Normal file
150
backend/src/users/repositories/users.repository.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import { UsersRepository } from "./users.repository";
|
||||||
|
|
||||||
|
describe("UsersRepository", () => {
|
||||||
|
let repository: UsersRepository;
|
||||||
|
let _databaseService: DatabaseService;
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
insert: jest.fn().mockReturnThis(),
|
||||||
|
values: jest.fn().mockReturnThis(),
|
||||||
|
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
from: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
limit: jest.fn().mockReturnThis(),
|
||||||
|
offset: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
delete: jest.fn().mockReturnThis(),
|
||||||
|
transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
UsersRepository,
|
||||||
|
{
|
||||||
|
provide: DatabaseService,
|
||||||
|
useValue: { db: mockDb },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
repository = module.get<UsersRepository>(UsersRepository);
|
||||||
|
_databaseService = module.get<DatabaseService>(DatabaseService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(repository).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should insert a user", async () => {
|
||||||
|
const data = {
|
||||||
|
username: "u",
|
||||||
|
email: "e",
|
||||||
|
passwordHash: "p",
|
||||||
|
emailHash: "eh",
|
||||||
|
};
|
||||||
|
await repository.create(data);
|
||||||
|
expect(mockDb.insert).toHaveBeenCalled();
|
||||||
|
expect(mockDb.values).toHaveBeenCalledWith(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findByEmailHash", () => {
|
||||||
|
it("should select user by email hash", async () => {
|
||||||
|
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||||
|
const result = await repository.findByEmailHash("hash");
|
||||||
|
expect(result.uuid).toBe("u1");
|
||||||
|
expect(mockDb.select).toHaveBeenCalled();
|
||||||
|
expect(mockDb.where).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOneWithPrivateData", () => {
|
||||||
|
it("should select user with private data", async () => {
|
||||||
|
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||||
|
const result = await repository.findOneWithPrivateData("u1");
|
||||||
|
expect(result.uuid).toBe("u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countAll", () => {
|
||||||
|
it("should return count", async () => {
|
||||||
|
mockDb.from.mockResolvedValueOnce([{ count: 5 }]);
|
||||||
|
const result = await repository.countAll();
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should select users with limit and offset", async () => {
|
||||||
|
mockDb.offset.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||||
|
const result = await repository.findAll(10, 0);
|
||||||
|
expect(result[0].uuid).toBe("u1");
|
||||||
|
expect(mockDb.limit).toHaveBeenCalledWith(10);
|
||||||
|
expect(mockDb.offset).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findByUsername", () => {
|
||||||
|
it("should find by username", async () => {
|
||||||
|
mockDb.limit.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||||
|
const result = await repository.findByUsername("u");
|
||||||
|
expect(result.uuid).toBe("u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should update user", async () => {
|
||||||
|
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||||
|
await repository.update("u1", { displayName: "New" });
|
||||||
|
expect(mockDb.update).toHaveBeenCalled();
|
||||||
|
expect(mockDb.set).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTwoFactorSecret", () => {
|
||||||
|
it("should return secret", async () => {
|
||||||
|
mockDb.limit.mockResolvedValueOnce([{ secret: "s" }]);
|
||||||
|
const result = await repository.getTwoFactorSecret("u1");
|
||||||
|
expect(result).toBe("s");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserContents", () => {
|
||||||
|
it("should return contents", async () => {
|
||||||
|
mockDb.where.mockResolvedValueOnce([{ id: "c1" }]);
|
||||||
|
const result = await repository.getUserContents("u1");
|
||||||
|
expect(result[0].id).toBe("c1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("softDeleteUserAndContents", () => {
|
||||||
|
it("should run transaction", async () => {
|
||||||
|
const mockTx = {
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
returning: jest.fn().mockResolvedValue([{ uuid: "u1" }]),
|
||||||
|
};
|
||||||
|
mockDb.transaction.mockImplementation(async (cb) => cb(mockTx));
|
||||||
|
|
||||||
|
const result = await repository.softDeleteUserAndContents("u1");
|
||||||
|
expect(result[0].uuid).toBe("u1");
|
||||||
|
expect(mockTx.update).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("purgeDeleted", () => {
|
||||||
|
it("should delete old deleted users", async () => {
|
||||||
|
mockDb.returning.mockResolvedValueOnce([{ uuid: "u1" }]);
|
||||||
|
const _result = await repository.purgeDeleted(new Date());
|
||||||
|
expect(mockDb.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { and, eq, lte, sql } from "drizzle-orm";
|
import { and, eq, ilike, lte, or, sql } from "drizzle-orm";
|
||||||
import { DatabaseService } from "../../database/database.service";
|
import { DatabaseService } from "../../database/database.service";
|
||||||
import { contents, favorites, users } from "../../database/schemas";
|
import { contents, favorites, users } from "../../database/schemas";
|
||||||
|
|
||||||
@@ -47,6 +47,8 @@ export class UsersRepository {
|
|||||||
bio: users.bio,
|
bio: users.bio,
|
||||||
status: users.status,
|
status: users.status,
|
||||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||||
|
showOnlineStatus: users.showOnlineStatus,
|
||||||
|
showReadReceipts: users.showReadReceipts,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
updatedAt: users.updatedAt,
|
updatedAt: users.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -64,7 +66,7 @@ export class UsersRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAll(limit: number, offset: number) {
|
async findAll(limit: number, offset: number) {
|
||||||
return await this.databaseService.db
|
const result = await this.databaseService.db
|
||||||
.select({
|
.select({
|
||||||
uuid: users.uuid,
|
uuid: users.uuid,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
@@ -77,6 +79,8 @@ export class UsersRepository {
|
|||||||
.from(users)
|
.from(users)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUsername(username: string) {
|
async findByUsername(username: string) {
|
||||||
@@ -95,6 +99,24 @@ export class UsersRepository {
|
|||||||
return result[0] || null;
|
return result[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async search(query: string) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({
|
||||||
|
uuid: users.uuid,
|
||||||
|
username: users.username,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
ilike(users.username, `%${query}%`),
|
||||||
|
ilike(users.displayName, `%${query}%`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(uuid: string) {
|
async findOne(uuid: string) {
|
||||||
const result = await this.databaseService.db
|
const result = await this.databaseService.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
192
backend/src/users/users.controller.spec.ts
Normal file
192
backend/src/users/users.controller.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
jest.mock("uuid", () => ({
|
||||||
|
v4: jest.fn(() => "mocked-uuid"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||||
|
ml_kem768: {
|
||||||
|
keygen: jest.fn(),
|
||||||
|
encapsulate: jest.fn(),
|
||||||
|
decapsulate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("jose", () => ({
|
||||||
|
SignJWT: jest.fn().mockReturnValue({
|
||||||
|
setProtectedHeader: jest.fn().mockReturnThis(),
|
||||||
|
setIssuedAt: jest.fn().mockReturnThis(),
|
||||||
|
setExpirationTime: jest.fn().mockReturnThis(),
|
||||||
|
sign: jest.fn().mockResolvedValue("mocked-jwt"),
|
||||||
|
}),
|
||||||
|
jwtVerify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AuthService } from "../auth/auth.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||||
|
import { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { UsersController } from "./users.controller";
|
||||||
|
import { UsersService } from "./users.service";
|
||||||
|
|
||||||
|
describe("UsersController", () => {
|
||||||
|
let controller: UsersController;
|
||||||
|
let usersService: UsersService;
|
||||||
|
let authService: AuthService;
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findPublicProfile: jest.fn(),
|
||||||
|
findOneWithPrivateData: jest.fn(),
|
||||||
|
exportUserData: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateAvatar: jest.fn(),
|
||||||
|
updateConsent: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthService = {
|
||||||
|
generateTwoFactorSecret: jest.fn(),
|
||||||
|
enableTwoFactor: jest.fn(),
|
||||||
|
disableTwoFactor: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCacheManager = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [
|
||||||
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
|
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<UsersController>(UsersController);
|
||||||
|
usersService = module.get<UsersService>(UsersService);
|
||||||
|
authService = module.get<AuthService>(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should call usersService.findAll", async () => {
|
||||||
|
await controller.findAll(10, 0);
|
||||||
|
expect(usersService.findAll).toHaveBeenCalledWith(10, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findPublicProfile", () => {
|
||||||
|
it("should call usersService.findPublicProfile", async () => {
|
||||||
|
await controller.findPublicProfile("testuser");
|
||||||
|
expect(usersService.findPublicProfile).toHaveBeenCalledWith("testuser");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findMe", () => {
|
||||||
|
it("should call usersService.findOneWithPrivateData", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.findMe(req);
|
||||||
|
expect(usersService.findOneWithPrivateData).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportMe", () => {
|
||||||
|
it("should call usersService.exportUserData", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.exportMe(req);
|
||||||
|
expect(usersService.exportUserData).toHaveBeenCalledWith("user-uuid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateMe", () => {
|
||||||
|
it("should call usersService.update", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const dto = { displayName: "New Name" };
|
||||||
|
await controller.updateMe(req, dto);
|
||||||
|
expect(usersService.update).toHaveBeenCalledWith("user-uuid", dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateAvatar", () => {
|
||||||
|
it("should call usersService.updateAvatar", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const file = {} as Express.Multer.File;
|
||||||
|
await controller.updateAvatar(req, file);
|
||||||
|
expect(usersService.updateAvatar).toHaveBeenCalledWith("user-uuid", file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateConsent", () => {
|
||||||
|
it("should call usersService.updateConsent", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
const dto = { termsVersion: "1.0", privacyVersion: "1.0" };
|
||||||
|
await controller.updateConsent(req, dto);
|
||||||
|
expect(usersService.updateConsent).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"1.0",
|
||||||
|
"1.0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeMe", () => {
|
||||||
|
it("should call usersService.remove", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.removeMe(req);
|
||||||
|
expect(usersService.remove).toHaveBeenCalledWith("user-uuid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeAdmin", () => {
|
||||||
|
it("should call usersService.remove", async () => {
|
||||||
|
await controller.removeAdmin("target-uuid");
|
||||||
|
expect(usersService.remove).toHaveBeenCalledWith("target-uuid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setup2fa", () => {
|
||||||
|
it("should call authService.generateTwoFactorSecret", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.setup2fa(req);
|
||||||
|
expect(authService.generateTwoFactorSecret).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enable2fa", () => {
|
||||||
|
it("should call authService.enableTwoFactor", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.enable2fa(req, "token123");
|
||||||
|
expect(authService.enableTwoFactor).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"token123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disable2fa", () => {
|
||||||
|
it("should call authService.disableTwoFactor", async () => {
|
||||||
|
const req = { user: { sub: "user-uuid" } } as AuthenticatedRequest;
|
||||||
|
await controller.disable2fa(req, "token123");
|
||||||
|
expect(authService.disableTwoFactor).toHaveBeenCalledWith(
|
||||||
|
"user-uuid",
|
||||||
|
"token123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,6 +54,12 @@ export class UsersController {
|
|||||||
return this.usersService.findPublicProfile(username);
|
return this.usersService.findPublicProfile(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("search")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
search(@Query("q") query: string) {
|
||||||
|
return this.usersService.search(query);
|
||||||
|
}
|
||||||
|
|
||||||
// Gestion de son propre compte
|
// Gestion de son propre compte
|
||||||
@Get("me")
|
@Get("me")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -112,6 +118,16 @@ export class UsersController {
|
|||||||
return this.usersService.remove(uuid);
|
return this.usersService.remove(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch("admin/:uuid")
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Roles("admin")
|
||||||
|
updateAdmin(
|
||||||
|
@Param("uuid") uuid: string,
|
||||||
|
@Body() updateUserDto: UpdateUserDto,
|
||||||
|
) {
|
||||||
|
return this.usersService.update(uuid, updateUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
// Double Authentification (2FA)
|
// Double Authentification (2FA)
|
||||||
@Post("me/2fa/setup")
|
@Post("me/2fa/setup")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { MediaModule } from "../media/media.module";
|
import { MediaModule } from "../media/media.module";
|
||||||
|
import { RealtimeModule } from "../realtime/realtime.module";
|
||||||
import { S3Module } from "../s3/s3.module";
|
import { S3Module } from "../s3/s3.module";
|
||||||
import { UsersRepository } from "./repositories/users.repository";
|
import { UsersRepository } from "./repositories/users.repository";
|
||||||
import { UsersController } from "./users.controller";
|
import { UsersController } from "./users.controller";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
|
imports: [
|
||||||
|
forwardRef(() => AuthModule),
|
||||||
|
MediaModule,
|
||||||
|
S3Module,
|
||||||
|
forwardRef(() => RealtimeModule),
|
||||||
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [UsersService, UsersRepository],
|
providers: [UsersService, UsersRepository],
|
||||||
exports: [UsersService, UsersRepository],
|
exports: [UsersService, UsersRepository],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { RbacService } from "../auth/rbac.service";
|
import { RbacService } from "../auth/rbac.service";
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from "../media/media.service";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { UsersRepository } from "./repositories/users.repository";
|
import { UsersRepository } from "./repositories/users.repository";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
@@ -49,6 +50,7 @@ describe("UsersService", () => {
|
|||||||
|
|
||||||
const mockRbacService = {
|
const mockRbacService = {
|
||||||
getUserRoles: jest.fn(),
|
getUserRoles: jest.fn(),
|
||||||
|
assignRoleToUser: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMediaService = {
|
const mockMediaService = {
|
||||||
@@ -65,6 +67,11 @@ describe("UsersService", () => {
|
|||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockEventsGateway = {
|
||||||
|
isUserOnline: jest.fn(),
|
||||||
|
broadcastStatus: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
@@ -77,6 +84,7 @@ describe("UsersService", () => {
|
|||||||
{ provide: MediaService, useValue: mockMediaService },
|
{ provide: MediaService, useValue: mockMediaService },
|
||||||
{ provide: S3Service, useValue: mockS3Service },
|
{ provide: S3Service, useValue: mockS3Service },
|
||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -108,6 +116,7 @@ describe("UsersService", () => {
|
|||||||
describe("findOne", () => {
|
describe("findOne", () => {
|
||||||
it("should find a user", async () => {
|
it("should find a user", async () => {
|
||||||
mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" });
|
mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" });
|
||||||
|
mockRbacService.getUserRoles.mockResolvedValue([]);
|
||||||
const result = await service.findOne("uuid1");
|
const result = await service.findOne("uuid1");
|
||||||
expect(result.uuid).toBe("uuid1");
|
expect(result.uuid).toBe("uuid1");
|
||||||
});
|
});
|
||||||
@@ -128,4 +137,113 @@ describe("UsersService", () => {
|
|||||||
expect(result[0].displayName).toBe("New");
|
expect(result[0].displayName).toBe("New");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("clearUserCache", () => {
|
||||||
|
it("should delete cache", async () => {
|
||||||
|
await service.clearUserCache("u1");
|
||||||
|
expect(mockCacheManager.del).toHaveBeenCalledWith("users/profile/u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findByEmailHash", () => {
|
||||||
|
it("should call repository.findByEmailHash", async () => {
|
||||||
|
mockUsersRepository.findByEmailHash.mockResolvedValue({ uuid: "u1" });
|
||||||
|
mockRbacService.getUserRoles.mockResolvedValue([]);
|
||||||
|
const result = await service.findByEmailHash("hash");
|
||||||
|
expect(result.uuid).toBe("u1");
|
||||||
|
expect(mockUsersRepository.findByEmailHash).toHaveBeenCalledWith("hash");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOneWithPrivateData", () => {
|
||||||
|
it("should return user with roles", async () => {
|
||||||
|
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
|
||||||
|
mockRbacService.getUserRoles.mockResolvedValue(["admin"]);
|
||||||
|
const result = await service.findOneWithPrivateData("u1");
|
||||||
|
expect(result.roles).toEqual(["admin"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should return all users", async () => {
|
||||||
|
mockUsersRepository.findAll.mockResolvedValue([{ uuid: "u1" }]);
|
||||||
|
mockUsersRepository.countAll.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.findAll(10, 0);
|
||||||
|
|
||||||
|
expect(result.totalCount).toBe(1);
|
||||||
|
expect(result.data[0].uuid).toBe("u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findPublicProfile", () => {
|
||||||
|
it("should return public profile", async () => {
|
||||||
|
mockUsersRepository.findByUsername.mockResolvedValue({
|
||||||
|
uuid: "u1",
|
||||||
|
username: "u1",
|
||||||
|
});
|
||||||
|
const result = await service.findPublicProfile("u1");
|
||||||
|
expect(result.username).toBe("u1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateConsent", () => {
|
||||||
|
it("should update consent", async () => {
|
||||||
|
await service.updateConsent("u1", "v1", "v2");
|
||||||
|
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
|
||||||
|
termsVersion: "v1",
|
||||||
|
privacyVersion: "v2",
|
||||||
|
gdprAcceptedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setTwoFactorSecret", () => {
|
||||||
|
it("should set 2fa secret", async () => {
|
||||||
|
await service.setTwoFactorSecret("u1", "secret");
|
||||||
|
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
|
||||||
|
twoFactorSecret: "secret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleTwoFactor", () => {
|
||||||
|
it("should toggle 2fa", async () => {
|
||||||
|
await service.toggleTwoFactor("u1", true);
|
||||||
|
expect(mockUsersRepository.update).toHaveBeenCalledWith("u1", {
|
||||||
|
isTwoFactorEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTwoFactorSecret", () => {
|
||||||
|
it("should return 2fa secret", async () => {
|
||||||
|
mockUsersRepository.getTwoFactorSecret.mockResolvedValue("secret");
|
||||||
|
const result = await service.getTwoFactorSecret("u1");
|
||||||
|
expect(result).toBe("secret");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportUserData", () => {
|
||||||
|
it("should return all user data", async () => {
|
||||||
|
mockUsersRepository.findOneWithPrivateData.mockResolvedValue({ uuid: "u1" });
|
||||||
|
mockUsersRepository.getUserContents.mockResolvedValue([]);
|
||||||
|
mockUsersRepository.getUserFavorites.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.exportUserData("u1");
|
||||||
|
|
||||||
|
expect(result.profile).toBeDefined();
|
||||||
|
expect(result.contents).toBeDefined();
|
||||||
|
expect(result.favorites).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should soft delete user", async () => {
|
||||||
|
await service.remove("u1");
|
||||||
|
expect(mockUsersRepository.softDeleteUserAndContents).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { RbacService } from "../auth/rbac.service";
|
|||||||
import type { IMediaService } from "../common/interfaces/media.interface";
|
import type { IMediaService } from "../common/interfaces/media.interface";
|
||||||
import type { IStorageService } from "../common/interfaces/storage.interface";
|
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from "../media/media.service";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
import { S3Service } from "../s3/s3.service";
|
import { S3Service } from "../s3/s3.service";
|
||||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||||
import { UsersRepository } from "./repositories/users.repository";
|
import { UsersRepository } from "./repositories/users.repository";
|
||||||
@@ -27,6 +28,8 @@ export class UsersService {
|
|||||||
private readonly rbacService: RbacService,
|
private readonly rbacService: RbacService,
|
||||||
@Inject(MediaService) private readonly mediaService: IMediaService,
|
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||||
@Inject(S3Service) private readonly s3Service: IStorageService,
|
@Inject(S3Service) private readonly s3Service: IStorageService,
|
||||||
|
@Inject(forwardRef(() => EventsGateway))
|
||||||
|
private readonly eventsGateway: EventsGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async clearUserCache(username?: string) {
|
private async clearUserCache(username?: string) {
|
||||||
@@ -45,7 +48,19 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findByEmailHash(emailHash: string) {
|
async findByEmailHash(emailHash: string) {
|
||||||
return await this.usersRepository.findByEmailHash(emailHash);
|
const user = await this.usersRepository.findByEmailHash(emailHash);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const roles = await this.rbacService.getUserRoles(user.uuid);
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
role: roles.includes("admin")
|
||||||
|
? "admin"
|
||||||
|
: roles.includes("moderator")
|
||||||
|
? "moderator"
|
||||||
|
: "user",
|
||||||
|
roles,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOneWithPrivateData(uuid: string) {
|
async findOneWithPrivateData(uuid: string) {
|
||||||
@@ -94,16 +109,63 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async search(query: string) {
|
||||||
|
const users = await this.usersRepository.search(query);
|
||||||
|
return users.map((user) => ({
|
||||||
|
...user,
|
||||||
|
avatarUrl: user.avatarUrl
|
||||||
|
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(uuid: string) {
|
async findOne(uuid: string) {
|
||||||
return await this.usersRepository.findOne(uuid);
|
const user = await this.usersRepository.findOne(uuid);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const roles = await this.rbacService.getUserRoles(user.uuid);
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
role: roles.includes("admin")
|
||||||
|
? "admin"
|
||||||
|
: roles.includes("moderator")
|
||||||
|
? "moderator"
|
||||||
|
: "user",
|
||||||
|
roles,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(uuid: string, data: UpdateUserDto) {
|
async update(uuid: string, data: UpdateUserDto) {
|
||||||
this.logger.log(`Updating user profile for ${uuid}`);
|
this.logger.log(`Updating user profile for ${uuid}`);
|
||||||
const result = await this.usersRepository.update(uuid, data);
|
|
||||||
|
const { role, ...userData } = data;
|
||||||
|
|
||||||
|
// On récupère l'utilisateur actuel avant mise à jour pour comparer les préférences
|
||||||
|
const oldUser = await this.usersRepository.findOne(uuid);
|
||||||
|
|
||||||
|
const result = await this.usersRepository.update(uuid, userData);
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
await this.rbacService.assignRoleToUser(uuid, role);
|
||||||
|
}
|
||||||
|
|
||||||
if (result[0]) {
|
if (result[0]) {
|
||||||
await this.clearUserCache(result[0].username);
|
await this.clearUserCache(result[0].username);
|
||||||
|
|
||||||
|
// Gérer le changement de préférence de statut en ligne
|
||||||
|
if (
|
||||||
|
data.showOnlineStatus !== undefined &&
|
||||||
|
data.showOnlineStatus !== oldUser?.showOnlineStatus
|
||||||
|
) {
|
||||||
|
const isOnline = this.eventsGateway.isUserOnline(uuid);
|
||||||
|
if (isOnline) {
|
||||||
|
if (data.showOnlineStatus) {
|
||||||
|
this.eventsGateway.broadcastStatus(uuid, "online");
|
||||||
|
} else {
|
||||||
|
this.eventsGateway.broadcastStatus(uuid, "offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ services:
|
|||||||
POSTGRES_DB: ${POSTGRES_DB:-app}
|
POSTGRES_DB: ${POSTGRES_DB:-app}
|
||||||
networks:
|
networks:
|
||||||
- nw_memegoat
|
- nw_memegoat
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432" # not exposed to WAN, LAN only for administration checkup
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -35,6 +37,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- nw_memegoat
|
- nw_memegoat
|
||||||
|
- nw_caddy
|
||||||
#ports:
|
#ports:
|
||||||
# - "9000:9000"
|
# - "9000:9000"
|
||||||
# - "9001:9001"
|
# - "9001:9001"
|
||||||
@@ -128,6 +131,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://memegoat.fr}
|
||||||
|
NEXT_PUBLIC_CONTACT_EMAIL: ${MAIL_FROM:-noreply@memegoat.fr}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ COPY documentation/package.json ./documentation/
|
|||||||
|
|
||||||
# Montage du cache pnpm
|
# Montage du cache pnpm
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile --force
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Deuxième passe avec cache pour les scripts/liens
|
# Deuxième passe avec cache pour les scripts/liens
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile --force
|
||||||
|
|
||||||
# Build avec cache Next.js
|
# Build avec cache Next.js
|
||||||
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
|
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"features": "Fonctionnalités",
|
"features": "Fonctionnalités",
|
||||||
"stack": "Stack Technologique",
|
"stack": "Stack Technologique",
|
||||||
"database": "Modèle de Données",
|
"database": "Modèle de Données",
|
||||||
|
"flows": "Flux Métiers",
|
||||||
"---security---": {
|
"---security---": {
|
||||||
"type": "separator",
|
"type": "separator",
|
||||||
"label": "Sécurité & Conformité"
|
"label": "Sécurité & Conformité"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user