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:
@@ -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']);
|
||||
|
||||
@@ -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}>
|
||||
*/
|
||||
|
||||
75
tests/js/api-env-switcher.test.js
Normal file
75
tests/js/api-env-switcher.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
1
translations/messages.fr.yaml
Normal file
1
translations/messages.fr.yaml
Normal file
@@ -0,0 +1 @@
|
||||
test_1: Bonjour
|
||||
Reference in New Issue
Block a user