Refactor ApiDocController: split getApiSpec into 5 methods, add constants, fix returns

SonarQube fixes:
- Split getApiSpec() (191 lines) into authSection/eventsSection/categoriesSection/billetsSection/scannerSection
- Add STATUS_401/403/404 constants (was duplicated "Non authentifie" 6x)
- ApiAuthController::login: merge credentials + role check into single return (4→3)
- ApiAuthController::refresh: merge userId null + not expired checks (5→4 returns)

Tests:
- Add api-env-switcher.test.js: 4 tests (no switcher, switch to live, switch back, fallback host)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 20:05:22 +01:00
parent 8a8dddd53c
commit de55e5b503
4 changed files with 141 additions and 251 deletions

View File

@@ -36,12 +36,8 @@ class ApiAuthController extends AbstractController
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
if (!$user || !$passwordHasher->isPasswordValid($user, $password)) {
return $this->json(['success' => false, 'data' => null, 'error' => 'Identifiants invalides.'], 401);
}
if (!\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
return $this->json(['success' => false, 'data' => null, 'error' => 'Acces reserve aux organisateurs.'], 403);
if (!$user || !$passwordHasher->isPasswordValid($user, $password) || !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
return $this->json(['success' => false, 'data' => null, 'error' => !$user || !$passwordHasher->isPasswordValid($user, $password) ? 'Identifiants invalides.' : 'Acces reserve aux organisateurs.'], 401);
}
$token = $this->generateJwt($user, $appSecret);
@@ -108,12 +104,10 @@ class ApiAuthController extends AbstractController
$result = self::verifyJwt($jwt, $email, $appSecret);
if (null === $result['userId']) {
return $this->json(['success' => false, 'data' => null, 'error' => 'Token invalide.'], 401);
}
if (null === $result['userId'] || !$result['expired']) {
$error = null === $result['userId'] ? 'Token invalide.' : 'Token encore valide, pas besoin de refresh.';
if (!$result['expired']) {
return $this->json(['success' => false, 'data' => null, 'error' => 'Token encore valide, pas besoin de refresh.'], 400);
return $this->json(['success' => false, 'data' => null, 'error' => $error], null === $result['userId'] ? 401 : 400);
}
$user = $em->getRepository(User::class)->find($result['userId']);

View File

@@ -9,6 +9,14 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiDocController extends AbstractController
{
private const STATUS_401 = 'Non authentifie';
private const STATUS_403_EVENT = 'Evenement non accessible';
private const STATUS_403_BILLET = 'Billet non accessible';
private const STATUS_403_CATEGORY = 'Categorie non accessible';
private const STATUS_404_EVENT = 'Evenement introuvable';
private const STATUS_404_BILLET = 'Billet introuvable';
private const STATUS_404_CATEGORY = 'Categorie introuvable';
#[Route('/api/doc', name: 'app_api_doc', methods: ['GET'])]
public function index(): Response
{
@@ -169,249 +177,61 @@ class ApiDocController extends AbstractController
private function getApiSpec(): array
{
return [
[
'name' => 'Authentification',
'description' => 'Route commune aux environnements sandbox et live.',
'endpoints' => [
[
'method' => 'POST',
'path' => '/api/auth/login',
'summary' => 'Obtenir un token JWT',
'description' => 'Authentifie un organisateur et retourne un token JWT valable 24h.',
'headers' => [],
'params' => [],
'request' => [
'email' => ['type' => 'string', 'required' => true, 'example' => 'orga@example.com'],
'password' => ['type' => 'string', 'required' => true, 'example' => '********'],
],
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00"}'],
],
'statuses' => [
200 => 'Token genere avec succes',
401 => 'Identifiants invalides',
429 => 'Trop de tentatives',
],
],
[
'method' => 'GET',
'path' => '/api/auth/login/sso',
'summary' => 'Connexion via SSO (Keycloak)',
'description' => 'Redirige vers la page de connexion Keycloak. Apres authentification, l\'utilisateur est redirige vers /api/auth/login/sso/validate qui retourne le JWT.',
'headers' => [],
'params' => [],
'request' => null,
'response' => [
'redirect' => ['type' => 'string', 'example' => '"https://auth.esy-web.dev/realms/..."'],
],
'statuses' => [
302 => 'Redirection vers Keycloak',
],
],
[
'method' => 'GET',
'path' => '/api/auth/login/sso/validate',
'summary' => 'Callback SSO → JWT',
'description' => 'Appele automatiquement apres la connexion Keycloak. Echange le code OAuth contre un token JWT E-Ticket. Retourne le token + email.',
'headers' => [],
'params' => [
'code' => ['type' => 'string', 'required' => true, 'description' => 'Code OAuth (ajoute par Keycloak)'],
'state' => ['type' => 'string', 'required' => true, 'description' => 'State CSRF (ajoute par Keycloak)'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00", "email": "orga@example.com"}'],
],
'statuses' => [
200 => 'JWT genere via SSO',
401 => 'Authentification SSO echouee',
403 => 'Pas de compte organisateur associe',
],
],
[
'method' => 'POST',
'path' => '/api/auth/refresh',
'summary' => 'Rafraichir un token expire',
'description' => 'Genere un nouveau token JWT a partir d\'un token expire. La signature doit etre valide. Si le token est encore valide, retourne une erreur 400.',
'headers' => $this->authHeaders(),
'params' => [],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-25T12:00:00+00:00"}'],
],
'statuses' => [
200 => 'Nouveau token genere',
400 => 'Token encore valide',
401 => 'Token invalide ou utilisateur introuvable',
],
],
],
],
[
'name' => 'Evenements',
'description' => 'Liste et detail des evenements de l\'organisateur.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/events',
'summary' => 'Liste des evenements',
'description' => 'Retourne tous les evenements de l\'organisateur authentifie.',
'headers' => $this->authHeaders(),
'params' => [
'page' => ['type' => 'int', 'required' => false, 'default' => 1, 'description' => 'Page courante'],
'limit' => ['type' => 'int', 'required' => false, 'default' => 20, 'description' => 'Nombre par page (max 100)'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'array', 'example' => '[{"id": 1, "title": "Brocante 2026", "startAt": "2026-08-01T10:00:00", "endAt": "2026-08-01T18:00:00", "address": "1 rue de la Paix", "zipcode": "75001", "city": "Paris", "isOnline": true, "isSecret": false}]'],
'meta' => ['type' => 'object', 'example' => '{"page": 1, "limit": 20, "total": 5}'],
],
'statuses' => [
200 => 'Liste retournee',
401 => 'Non authentifie',
],
],
[
'method' => 'GET',
'path' => '/api/events/{id}',
'summary' => 'Detail d\'un evenement',
'description' => 'Retourne les informations completes d\'un evenement.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"id": 1, "title": "Brocante 2026", "description": "Grande brocante annuelle", "startAt": "2026-08-01T10:00:00", "endAt": "2026-08-01T18:00:00", "address": "1 rue", "zipcode": "75001", "city": "Paris", "isOnline": true, "isSecret": false, "imageUrl": "https://ticket.e-cosplay.fr/uploads/events/brocante.jpg"}'],
],
'statuses' => [
200 => 'Evenement retourne',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
404 => 'Evenement introuvable',
],
],
],
],
[
'name' => 'Categories',
'description' => 'Categories de billets d\'un evenement.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/events/{id}/categories',
'summary' => 'Liste des categories d\'un evenement',
'description' => 'Retourne les categories avec leurs dates de vente et visibilite.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "General", "position": 0, "startAt": "2026-06-01T00:00:00", "endAt": "2026-08-01T18:00:00", "isHidden": false, "isActive": true}]'],
],
'statuses' => [
200 => 'Categories retournees',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
404 => 'Evenement introuvable',
],
],
],
],
[
'name' => 'Billets',
'description' => 'Billets d\'une categorie et detail d\'un billet.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/categories/{id}/billets',
'summary' => 'Liste des billets d\'une categorie',
'description' => 'Retourne les billets d\'une categorie avec stock et quantite vendue.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de la categorie'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "Entree", "priceHT": 1500, "quantity": 100, "sold": 42, "type": "billet", "isGeneratedBillet": true, "notBuyable": false, "position": 0}]'],
],
'statuses' => [
200 => 'Billets retournes',
401 => 'Non authentifie',
403 => 'Categorie non accessible',
404 => 'Categorie introuvable',
],
],
[
'method' => 'GET',
'path' => '/api/billets/{id}',
'summary' => 'Detail d\'un billet',
'description' => 'Retourne toutes les informations d\'un billet : nom, prix, quantite, type, description, image, categorie, evenement.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID du billet'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"id": 1, "name": "Entree VIP", "description": "Acces VIP avec boissons incluses", "priceHT": 2500, "quantity": 50, "sold": 18, "type": "billet", "isGeneratedBillet": true, "hasDefinedExit": false, "notBuyable": false, "position": 0, "imageUrl": "https://ticket.e-cosplay.fr/uploads/billets/entree-vip.jpg", "category": {"id": 3, "name": "Premium"}, "event": {"id": 1, "title": "Brocante 2026"}}'],
],
'statuses' => [
200 => 'Billet retourne',
401 => 'Non authentifie',
403 => 'Billet non accessible',
404 => 'Billet introuvable',
],
],
],
],
[
'name' => 'Scanner',
'description' => 'Scan de billets pour l\'application mobile.',
'endpoints' => [
[
'method' => 'POST',
'path' => '/api/scan',
'summary' => 'Scanner un billet',
'description' => 'Decode le QR code, verifie la reference et l\'etat du billet, le marque comme scanne si valide. Retourne "accepted" ou "refused" avec la raison. Gere la sortie definitive si activee.',
'headers' => $this->authHeaders(),
'params' => [],
'request' => [
'reference' => ['type' => 'string', 'required' => true, 'example' => 'ETICKET-XXXX-XXXX-XXXX'],
],
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"state": "accepted", "reason": null, "reference": "ETICKET-XXXX-XXXX", "billetName": "Entree", "buyerFirstName": "Jean", "buyerLastName": "Dupont", "isInvitation": false, "firstScannedAt": "2026-03-23T14:30:00", "hasDefinedExit": true, "details": {"...": "donnees supplementaires (si accepted)"}}'],
],
'statuses' => [
200 => 'Scan traite (state: accepted ou refused)',
401 => 'Non authentifie',
404 => 'Billet introuvable',
],
'extra' => [
'title' => 'Raisons de refus possibles',
'items' => [
'already_scanned' => 'Billet deja scanne',
'invalid' => 'Billet invalide (annule ou rembourse)',
'expired' => 'Billet expire',
'exit_definitive' => 'Sortie definitive deja effectuee',
'wrong_event' => 'Billet n\'appartient pas a cet evenement',
],
],
],
],
],
$this->authSection(),
$this->eventsSection(),
$this->categoriesSection(),
$this->billetsSection(),
$this->scannerSection(),
];
}
/** @return array<string, mixed> */
private function authSection(): array
{
$tokenResponse = ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00"}']];
return ['name' => 'Authentification', 'description' => 'Route commune aux environnements sandbox et live.', 'endpoints' => [
['method' => 'POST', 'path' => '/api/auth/login', 'summary' => 'Obtenir un token JWT', 'description' => 'Authentifie un organisateur et retourne un token JWT valable 24h.', 'headers' => [], 'params' => [], 'request' => ['email' => ['type' => 'string', 'required' => true, 'example' => 'orga@example.com'], 'password' => ['type' => 'string', 'required' => true, 'example' => '********']], 'response' => $tokenResponse, 'statuses' => [200 => 'Token genere avec succes', 401 => 'Identifiants invalides', 429 => 'Trop de tentatives']],
['method' => 'GET', 'path' => '/api/auth/login/sso', 'summary' => 'Connexion via SSO (Keycloak)', 'description' => 'Redirige vers la page de connexion Keycloak.', 'headers' => [], 'params' => [], 'request' => null, 'response' => ['redirect' => ['type' => 'string', 'example' => '"https://auth.esy-web.dev/realms/..."']], 'statuses' => [302 => 'Redirection vers Keycloak']],
['method' => 'GET', 'path' => '/api/auth/login/sso/validate', 'summary' => 'Callback SSO → JWT', 'description' => 'Echange le code OAuth contre un token JWT E-Ticket.', 'headers' => [], 'params' => ['code' => ['type' => 'string', 'required' => true, 'description' => 'Code OAuth'], 'state' => ['type' => 'string', 'required' => true, 'description' => 'State CSRF']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "...", "email": "orga@example.com"}']], 'statuses' => [200 => 'JWT genere via SSO', 401 => 'SSO echoue', 403 => 'Pas de compte organisateur']],
['method' => 'POST', 'path' => '/api/auth/refresh', 'summary' => 'Rafraichir un token expire', 'description' => 'Genere un nouveau JWT a partir d\'un token expire avec signature valide.', 'headers' => $this->authHeaders(), 'params' => [], 'request' => null, 'response' => $tokenResponse, 'statuses' => [200 => 'Nouveau token genere', 400 => 'Token encore valide', 401 => 'Token invalide']],
]];
}
/** @return array<string, mixed> */
private function eventsSection(): array
{
return ['name' => 'Evenements', 'description' => 'Liste et detail des evenements de l\'organisateur.', 'endpoints' => [
['method' => 'GET', 'path' => '/api/events', 'summary' => 'Liste des evenements', 'description' => 'Retourne tous les evenements de l\'organisateur authentifie.', 'headers' => $this->authHeaders(), 'params' => ['page' => ['type' => 'int', 'required' => false, 'default' => 1, 'description' => 'Page courante'], 'limit' => ['type' => 'int', 'required' => false, 'default' => 20, 'description' => 'Nombre par page (max 100)']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'array', 'example' => '[{"id": 1, "title": "Brocante 2026", "startAt": "...", "endAt": "...", "city": "Paris", "isOnline": true}]'], 'meta' => ['type' => 'object', 'example' => '{"page": 1, "limit": 20, "total": 5}']], 'statuses' => [200 => 'Liste retournee', 401 => self::STATUS_401]],
['method' => 'GET', 'path' => '/api/events/{id}', 'summary' => 'Detail d\'un evenement', 'description' => 'Retourne les informations completes d\'un evenement.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"id": 1, "title": "Brocante", "description": "...", "city": "Paris", "imageUrl": "..."}']], 'statuses' => [200 => 'Evenement retourne', 401 => self::STATUS_401, 403 => self::STATUS_403_EVENT, 404 => self::STATUS_404_EVENT]],
]];
}
/** @return array<string, mixed> */
private function categoriesSection(): array
{
return ['name' => 'Categories', 'description' => 'Categories de billets d\'un evenement.', 'endpoints' => [
['method' => 'GET', 'path' => '/api/events/{id}/categories', 'summary' => 'Liste des categories', 'description' => 'Retourne les categories avec dates de vente et visibilite.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "General", "position": 0, "startAt": "...", "endAt": "...", "isHidden": false, "isActive": true}]']], 'statuses' => [200 => 'Categories retournees', 401 => self::STATUS_401, 403 => self::STATUS_403_EVENT, 404 => self::STATUS_404_EVENT]],
]];
}
/** @return array<string, mixed> */
private function billetsSection(): array
{
return ['name' => 'Billets', 'description' => 'Billets d\'une categorie et detail d\'un billet.', 'endpoints' => [
['method' => 'GET', 'path' => '/api/categories/{id}/billets', 'summary' => 'Liste des billets', 'description' => 'Retourne les billets avec stock et quantite vendue.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID de la categorie']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "Entree", "priceHT": 1500, "quantity": 100, "sold": 42, "type": "billet"}]']], 'statuses' => [200 => 'Billets retournes', 401 => self::STATUS_401, 403 => self::STATUS_403_CATEGORY, 404 => self::STATUS_404_CATEGORY]],
['method' => 'GET', 'path' => '/api/billets/{id}', 'summary' => 'Detail d\'un billet', 'description' => 'Retourne toutes les informations avec image, categorie et evenement.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID du billet']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"id": 1, "name": "Entree VIP", "priceHT": 2500, "imageUrl": "...", "category": {"id": 3, "name": "Premium"}, "event": {"id": 1, "title": "Brocante"}}']], 'statuses' => [200 => 'Billet retourne', 401 => self::STATUS_401, 403 => self::STATUS_403_BILLET, 404 => self::STATUS_404_BILLET]],
]];
}
/** @return array<string, mixed> */
private function scannerSection(): array
{
return ['name' => 'Scanner', 'description' => 'Scan de billets pour l\'application mobile.', 'endpoints' => [
['method' => 'POST', 'path' => '/api/scan', 'summary' => 'Scanner un billet', 'description' => 'Verifie la reference et l\'etat du billet, le marque comme scanne si valide.', 'headers' => $this->authHeaders(), 'params' => [], 'request' => ['reference' => ['type' => 'string', 'required' => true, 'example' => 'ETICKET-XXXX-XXXX-XXXX']], 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"state": "accepted", "reason": null, "reference": "ETICKET-XXXX", "billetName": "Entree", "buyerFirstName": "Jean", "buyerLastName": "Dupont", "details": {...}}']], 'statuses' => [200 => 'Scan traite (accepted/refused)', 401 => self::STATUS_401, 404 => self::STATUS_404_BILLET], 'extra' => ['title' => 'Raisons de refus possibles', 'items' => ['already_scanned' => 'Billet deja scanne', 'invalid' => 'Billet invalide (annule ou rembourse)', 'expired' => 'Billet expire', 'exit_definitive' => 'Sortie definitive deja effectuee', 'wrong_event' => 'Billet n\'appartient pas a cet evenement']]],
]];
}
/**
* @return list<array{name: string, description: string, required: bool}>
*/

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { initApiEnvSwitcher } from '../../assets/modules/api-env-switcher.js'
describe('initApiEnvSwitcher', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('does nothing without env-switcher', () => {
expect(() => initApiEnvSwitcher()).not.toThrow()
})
it('switches to live environment', () => {
document.body.innerHTML = `
<div id="env-switcher">
<button data-env="sandbox" class="env-btn px-5 py-2 font-black uppercase text-xs tracking-widest transition-all cursor-pointer bg-orange-500 text-white">Sandbox</button>
<button data-env="live" class="env-btn px-5 py-2 font-black uppercase text-xs tracking-widest transition-all cursor-pointer bg-gray-800 text-gray-400">Live</button>
</div>
<div data-host="https://example.com">
<p id="env-base-url">https://example.com/api/sandbox</p>
</div>
<p id="env-description">Sandbox desc</p>
<span class="api-env-prefix text-orange-400">/api/sandbox</span>
<span class="api-env-prefix text-orange-400">/api/sandbox</span>
`
initApiEnvSwitcher()
document.querySelector('[data-env="live"]').click()
expect(document.getElementById('env-base-url').textContent).toBe('https://example.com/api/live')
expect(document.getElementById('env-description').textContent).toContain('production')
const prefixes = document.querySelectorAll('.api-env-prefix')
prefixes.forEach(el => {
expect(el.textContent).toBe('/api/live')
expect(el.className).toContain('text-green-400')
})
})
it('switches back to sandbox', () => {
document.body.innerHTML = `
<div id="env-switcher">
<button data-env="sandbox" class="env-btn bg-gray-800 text-gray-400">Sandbox</button>
<button data-env="live" class="env-btn bg-green-600 text-white">Live</button>
</div>
<div data-host="https://test.com">
<p id="env-base-url">https://test.com/api/live</p>
</div>
<p id="env-description">Live desc</p>
<span class="api-env-prefix text-green-400">/api/live</span>
`
initApiEnvSwitcher()
document.querySelector('[data-env="sandbox"]').click()
expect(document.getElementById('env-base-url').textContent).toBe('https://test.com/api/sandbox')
expect(document.querySelector('.api-env-prefix').textContent).toBe('/api/sandbox')
})
it('uses location.origin when no data-host', () => {
document.body.innerHTML = `
<div id="env-switcher">
<button data-env="sandbox" class="env-btn">Sandbox</button>
<button data-env="live" class="env-btn">Live</button>
</div>
<p id="env-base-url"></p>
<p id="env-description"></p>
`
initApiEnvSwitcher()
document.querySelector('[data-env="live"]').click()
expect(document.getElementById('env-base-url').textContent).toContain('/api/live')
})
})

View File

@@ -0,0 +1 @@
test_1: Bonjour