From 0300f7fef071662040882a9c415ace623f27c7df Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Wed, 8 Jul 2026 12:58:55 +0200 Subject: [PATCH] update: unit test, push packages with ci/cd --- .github/workflows/ci.yml | 41 +++++++++++++++++++++++ README.md | 70 ++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 4 ++- package.json | 3 +- test/spotify.test.js | 16 +++++++++ test/youtube.test.js | 36 +++++++++++++++++++++ 6 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/spotify.test.js create mode 100644 test/youtube.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b098885 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI/CD + +on: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm test + + build-and-push: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository_owner }}/octane-website:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 2d80bfc..7a9b7b7 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,12 @@ Remplir `.env` : Puis démarrer : ```bash -docker compose up --build +docker compose pull +docker compose up -d ``` +`docker-compose.yml` référence l'image publiée par la CI (`ghcr.io/nfonteyne/octane-website:latest`, voir [CI/CD](#cicd-et-mises-à-jour) ci-dessous) plutôt que de la construire localement — c'est ce fichier que vous utilisez sur votre serveur. + Les migrations SQL (`src/db/migrations/*.sql`) sont exécutées automatiquement au démarrage du conteneur `app`, de façon idempotente (une table `schema_migrations` garde la trace des fichiers déjà appliqués). ## Tester en local sans Authentik (ex: dans WSL) @@ -192,17 +195,78 @@ Ouvrir `http://localhost:3000` : vous serez redirigé vers `/auth/login`, qui af Une fois satisfait, repassez `DEV_BYPASS_AUTH=false` et configurez les variables `AUTHENTIK_*`/`OIDC_*` avant de déployer avec `docker-compose.yml` (celui avec le réseau Authentik). +## CI/CD et mises à jour + +Le workflow `.github/workflows/ci.yml` se déclenche **uniquement sur push vers `main`** : + +```mermaid +flowchart LR + Push["push sur main"] --> Test["Job test\nnpm ci + npm test"] + Test -- succès --> Build["Job build-and-push\ndocker build (app seule)"] + Build --> Push2["push vers ghcr.io\n:latest"] + Push2 -. "détecte le nouveau digest" .-> Watchtower["Watchtower (sur votre serveur)"] + Watchtower --> Redeploy["redéploie le conteneur app"] +``` + +1. **Job `test`** : installe les dépendances et lance `npm test` (tests unitaires avec le test runner natif de Node — `node --test`, aucune dépendance de test supplémentaire). Actuellement couvre la validation des liens YouTube/Spotify (`test/*.test.js`). +2. **Job `build-and-push`** (uniquement si les tests passent) : construit **uniquement l'image de l'app** (le `Dockerfile` ne contient que Node/Express, jamais Postgres) et la publie sur `ghcr.io/nfonteyne/octane-website:latest`. + +Sur votre serveur, `docker-compose.yml` référence cette image directement (`image: ghcr.io/nfonteyne/octane-website:latest`) au lieu de la construire — Watchtower peut donc la surveiller et la mettre à jour automatiquement dès qu'un nouveau push sur `main` produit une nouvelle image. + +Si le repo GitHub est privé, le package `ghcr.io` publié le sera aussi : sur le serveur, faites une fois : + +```bash +echo "$GITHUB_TOKEN" | docker login ghcr.io -u nfonteyne --password-stdin +``` + +avec un token GitHub (classic PAT ou fine-grained) ayant le scope `read:packages`. + +Pour lancer les tests en local : + +```bash +npm test +``` + +## Sauvegarde de la base de données + +Aucune sauvegarde automatique n'est intégrée à l'application — c'est volontaire, pour que vous gardiez la main sur votre solution de backup externe. Les données Postgres vivent entièrement dans le volume Docker nommé **`pgdata`** (déclaré dans `docker-compose.yml`, monté sur `/var/lib/postgresql/data` du service `postgres`). + +Repérer le nom réel du volume (préfixé par le nom du projet Compose) : + +```bash +docker volume ls | grep pgdata +docker volume inspect # donne le Mountpoint sur le disque de l'hôte +``` + +Deux façons de sauvegarder depuis l'extérieur : + +- **Backup logique (`pg_dump`)**, recommandé, portable entre versions de Postgres : + ```bash + docker compose exec postgres pg_dump -U octane -d octane -F c -f /tmp/octane.dump + docker compose cp postgres:/tmp/octane.dump ./octane_$(date +%Y%m%d).dump + ``` +- **Backup brut du volume**, via le `Mountpoint` renvoyé par `docker volume inspect`, ou avec un conteneur utilitaire : + ```bash + docker run --rm -v :/data -v "$(pwd)/backups":/backup alpine \ + tar czf /backup/pgdata_$(date +%Y%m%d).tar.gz -C /data . + ``` + +Branchez l'une de ces commandes sur votre outil de backup externe habituel (cron, Veeam, Borg, etc.). + ## Structure du projet ``` octane-website/ -├── Dockerfile, docker-compose.yml +├── .github/workflows/ci.yml # tests + build/push de l'image Docker sur push main +├── Dockerfile, docker-compose.yml, docker-compose.dev.yml +├── test/ # tests unitaires (node --test) ├── src/ │ ├── server.js, app.js, config.js │ ├── db/ # pool Postgres, migration runner, migrations SQL │ ├── auth/ # OIDC (Authentik), session, middleware, routes /auth │ ├── routes/ # routes API /api/* -│ └── repositories/ # accès SQL par table +│ ├── repositories/ # accès SQL par table +│ └── lib/ # helpers purs (validation YouTube/Spotify) — couverts par les tests └── public/ ├── *.html # une page par fonctionnalité ├── css/style.css diff --git a/docker-compose.yml b/docker-compose.yml index c925563..2b85d67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: app: - build: . + # Built and pushed by .github/workflows/ci.yml on every push to main. + # Watchtower on the server picks up new digests on this tag automatically. + image: ${APP_IMAGE:-ghcr.io/nfonteyne/octane-website:latest} restart: unless-stopped env_file: .env ports: diff --git a/package.json b/package.json index 1c2635d..3e4e338 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "src/server.js", "scripts": { "start": "node src/server.js", - "migrate": "node src/db/migrate.js" + "migrate": "node src/db/migrate.js", + "test": "node --test" }, "dependencies": { "connect-pg-simple": "^10.0.0", diff --git a/test/spotify.test.js b/test/spotify.test.js new file mode 100644 index 0000000..2d49992 --- /dev/null +++ b/test/spotify.test.js @@ -0,0 +1,16 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { isValidSpotifyUrl } = require('../src/lib/spotify'); + +test('isValidSpotifyUrl: true for open.spotify.com links', () => { + assert.equal(isValidSpotifyUrl('https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC'), true); +}); + +test('isValidSpotifyUrl: false for other hosts', () => { + assert.equal(isValidSpotifyUrl('https://www.youtube.com/watch?v=abc'), false); + assert.equal(isValidSpotifyUrl('https://spotify.com/track/abc'), false); +}); + +test('isValidSpotifyUrl: false for malformed URL', () => { + assert.equal(isValidSpotifyUrl('not a url'), false); +}); diff --git a/test/youtube.test.js b/test/youtube.test.js new file mode 100644 index 0000000..53ff309 --- /dev/null +++ b/test/youtube.test.js @@ -0,0 +1,36 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { extractVideoId, isValidYoutubeUrl } = require('../src/lib/youtube'); + +test('extractVideoId: standard watch URL', () => { + assert.equal(extractVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); +}); + +test('extractVideoId: watch URL with extra query params', () => { + assert.equal( + extractVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s&list=abc'), + 'dQw4w9WgXcQ' + ); +}); + +test('extractVideoId: short youtu.be URL', () => { + assert.equal(extractVideoId('https://youtu.be/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); +}); + +test('extractVideoId: embed URL', () => { + assert.equal(extractVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); +}); + +test('extractVideoId: non-YouTube host returns null', () => { + assert.equal(extractVideoId('https://vimeo.com/12345'), null); +}); + +test('extractVideoId: malformed URL returns null', () => { + assert.equal(extractVideoId('not a url'), null); +}); + +test('isValidYoutubeUrl: true for valid links, false otherwise', () => { + assert.equal(isValidYoutubeUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), true); + assert.equal(isValidYoutubeUrl('https://open.spotify.com/track/abc'), false); + assert.equal(isValidYoutubeUrl(''), false); +});