diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md index 142f245..6232334 100644 --- a/docs/specs/M1-clients/spec-front.md +++ b/docs/specs/M1-clients/spec-front.md @@ -261,7 +261,8 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse. - Composable dédié `useAddressAutocomplete()` (à créer en M1). - Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back. -- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse. +- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}` (sans filtre `type`) → suggestions adresse. + - ⚠ **Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu. - Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre. ## Points laissés ouverts par la V0 (résolus côté back) diff --git a/frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts b/frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts new file mode 100644 index 0000000..d3d7b5d --- /dev/null +++ b/frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + useAddressAutocomplete, + AddressAutocompleteUnavailableError, +} from '../useAddressAutocomplete' + +// On mocke le helper d'appel externe : aucun vrai appel reseau a la BAN. +// vi.mock est hoiste par Vitest au-dessus des imports. +const mockHttp = vi.hoisted(() => vi.fn()) +vi.mock('~/shared/utils/httpExternal', () => ({ httpExternal: mockHttp })) + +const BAN_URL = 'https://api-adresse.data.gouv.fr/search/' + +describe('useAddressAutocomplete', () => { + beforeEach(() => { + mockHttp.mockReset() + }) + + describe('searchCity', () => { + it('interroge la BAN en type=municipality et mappe { city, postalCode }', async () => { + mockHttp.mockResolvedValueOnce({ + type: 'FeatureCollection', + features: [ + { properties: { city: 'Amiens', postcode: '80000', name: 'Amiens', type: 'municipality' } }, + { properties: { city: 'Amiens', postcode: '80080', name: 'Amiens', type: 'municipality' } }, + ], + }) + + const { searchCity } = useAddressAutocomplete() + const res = await searchCity('80000') + + expect(mockHttp).toHaveBeenCalledWith( + BAN_URL, + expect.objectContaining({ query: { q: '80000', type: 'municipality' } }), + ) + expect(res).toEqual([ + { city: 'Amiens', postalCode: '80000' }, + { city: 'Amiens', postalCode: '80080' }, + ]) + }) + + it('throw une AddressAutocompleteUnavailableError sur erreur reseau / 5xx', async () => { + mockHttp.mockRejectedValueOnce(new Error('500 Server Error')) + + const { searchCity } = useAddressAutocomplete() + + await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError) + }) + + it('throw une AddressAutocompleteUnavailableError sur timeout', async () => { + mockHttp.mockRejectedValueOnce(new Error('The operation was aborted due to timeout')) + + const { searchCity } = useAddressAutocomplete() + + await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError) + }) + }) + + describe('searchAddress', () => { + it('interroge la BAN avec postcode et mappe la suggestion', async () => { + mockHttp.mockResolvedValueOnce({ + type: 'FeatureCollection', + features: [ + { + properties: { + label: '8 Boulevard du Port 80000 Amiens', + name: '8 Boulevard du Port', + street: 'Boulevard du Port', + postcode: '80000', + city: 'Amiens', + type: 'housenumber', + }, + }, + ], + }) + + const { searchAddress } = useAddressAutocomplete() + const res = await searchAddress('8 boulevard du port', '80000') + + expect(mockHttp).toHaveBeenCalledWith( + BAN_URL, + expect.objectContaining({ + query: { q: '8 boulevard du port', postcode: '80000' }, + }), + ) + expect(res).toEqual([ + { + label: '8 Boulevard du Port 80000 Amiens', + street: '8 Boulevard du Port', + postalCode: '80000', + city: 'Amiens', + }, + ]) + }) + + it('omet le parametre postcode quand aucun code postal n\'est fourni', async () => { + mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] }) + + const { searchAddress } = useAddressAutocomplete() + await searchAddress('8 boulevard du port') + + expect(mockHttp).toHaveBeenCalledWith( + BAN_URL, + expect.objectContaining({ + query: { q: '8 boulevard du port' }, + }), + ) + }) + + it('ne restreint PAS la recherche a type=housenumber (sinon la BAN ne renvoie rien tant qu\'aucun numero n\'est saisi)', async () => { + // Regression : avec `type=housenumber`, une saisie de nom de rue sans + // numero (ex: « boulevard du port ») renvoie 0 resultat cote BAN. + mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] }) + + const { searchAddress } = useAddressAutocomplete() + await searchAddress('boulevard du port', '80000') + + const sentQuery = mockHttp.mock.calls[0]?.[1]?.query as Record + expect(sentQuery.type).toBeUndefined() + }) + + it('throw une AddressAutocompleteUnavailableError sur erreur reseau', async () => { + mockHttp.mockRejectedValueOnce(new Error('network down')) + + const { searchAddress } = useAddressAutocomplete() + + await expect(searchAddress('8 boulevard du port', '80000')).rejects.toBeInstanceOf( + AddressAutocompleteUnavailableError, + ) + }) + }) +}) diff --git a/frontend/shared/composables/useAddressAutocomplete.ts b/frontend/shared/composables/useAddressAutocomplete.ts index 538447e..7854eb1 100644 --- a/frontend/shared/composables/useAddressAutocomplete.ts +++ b/frontend/shared/composables/useAddressAutocomplete.ts @@ -1,27 +1,29 @@ -// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66. +import { httpExternal } from '~/shared/utils/httpExternal' + +// Autocompletion d'adresse branchee sur la Base Adresse Nationale (BAN), +// `api-adresse.data.gouv.fr` — service public francais, gratuit, CORS ouvert. // -// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre -// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels -// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS -// changer leur signature ni l'usage côté composant. +// Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec +// M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` : +// la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra. // -// Contrat figé par ERP-66 (c'est lui qui fait foi) : +// Contrat (fige) : // searchCity(postalCode) -> liste { city, postalCode } // searchAddress(query, cp?) -> liste { label, street, postalCode, city } -// En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur, -// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText). -// -// Comportement du stub : les deux méthodes throw systématiquement → l'onglet -// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre, -// Code postal saisi manuellement). Aucun appel réseau n'est émis ici. +// En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError. +// Le composant consommateur catch, affiche un toast d'avertissement et bascule +// en saisie libre (MalioInputText). -/** Une suggestion de ville renvoyée à partir d'un code postal. */ +/** URL de l'endpoint de recherche BAN. */ +const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/' + +/** Une suggestion de ville renvoyee a partir d'un code postal. */ export interface CitySuggestion { city: string postalCode: string } -/** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */ +/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */ export interface AddressSuggestion { label: string street: string @@ -34,27 +36,82 @@ export interface AddressAutocomplete { searchAddress(query: string, postalCode?: string): Promise } -/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */ +/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */ export class AddressAutocompleteUnavailableError extends Error { constructor() { - // Message technique (non affiché tel quel) : le composant remonte son - // propre libellé i18n. Sert au debug / aux logs uniquement. - super('Address autocomplete (BAN) is not available yet — ERP-66 stub.') + // Message technique (non affiche tel quel) : le composant remonte son + // propre libelle i18n. Sert au debug / aux logs uniquement. + super('Address autocomplete (BAN) is not available.') this.name = 'AddressAutocompleteUnavailableError' } } -/** - * STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes - * échouent toujours, forçant le mode dégradé côté onglet Adresse. - */ +/** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */ +interface BanFeatureProperties { + label?: string + name?: string + street?: string + postcode?: string + city?: string +} + +/** Reponse GeoJSON FeatureCollection de la BAN. */ +interface BanResponse { + features?: { properties?: BanFeatureProperties }[] +} + export function useAddressAutocomplete(): AddressAutocomplete { return { - async searchCity(_postalCode: string): Promise { - throw new AddressAutocompleteUnavailableError() + async searchCity(postalCode: string): Promise { + let res: BanResponse + try { + res = await httpExternal(BAN_SEARCH_URL, { + query: { q: postalCode, type: 'municipality' }, + }) + } catch { + // Reseau coupe, 5xx, timeout... -> mode degrade cote composant. + throw new AddressAutocompleteUnavailableError() + } + + return (res.features ?? []).map((feature) => { + const props = feature.properties ?? {} + return { + city: props.city ?? props.name ?? '', + postalCode: props.postcode ?? '', + } + }) }, - async searchAddress(_query: string, _postalCode?: string): Promise { - throw new AddressAutocompleteUnavailableError() + + async searchAddress(query: string, postalCode?: string): Promise { + // IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un + // resultat de ce type qu'une fois un numero saisi → une recherche par + // nom de rue (« boulevard du port ») renverrait 0 resultat pendant + // toute la frappe. Sans filtre `type`, la BAN classe rues + numeros + // par pertinence (comportement d'autocompletion attendu). + // On n'ajoute `postcode` que s'il est fourni (sinon recherche large). + const banQuery: Record = { q: query } + if (postalCode) { + banQuery.postcode = postalCode + } + + let res: BanResponse + try { + res = await httpExternal(BAN_SEARCH_URL, { query: banQuery }) + } catch { + throw new AddressAutocompleteUnavailableError() + } + + return (res.features ?? []).map((feature) => { + const props = feature.properties ?? {} + return { + label: props.label ?? '', + // `name` porte la ligne d'adresse complete (numero + voie) ; + // `street` ne contient que la voie. On privilegie `name`. + street: props.name ?? props.street ?? '', + postalCode: props.postcode ?? '', + city: props.city ?? '', + } + }) }, } } diff --git a/frontend/shared/utils/__tests__/httpExternal.test.ts b/frontend/shared/utils/__tests__/httpExternal.test.ts new file mode 100644 index 0000000..7a482e5 --- /dev/null +++ b/frontend/shared/utils/__tests__/httpExternal.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { httpExternal } from '../httpExternal' + +// On mocke ofetch : httpExternal s'appuie sur $fetch sans jamais toucher le +// reseau pendant les tests. vi.mock est hoiste par Vitest au-dessus des imports. +const mockFetch = vi.hoisted(() => vi.fn()) +vi.mock('ofetch', () => ({ $fetch: mockFetch })) + +describe('httpExternal', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('retourne le JSON parse renvoye par $fetch', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }) + + const res = await httpExternal<{ ok: boolean }>('https://example.test/api') + + expect(res).toEqual({ ok: true }) + }) + + it('transmet la query, coupe le cookie (credentials omit) et pose un timeout par defaut', async () => { + mockFetch.mockResolvedValueOnce([]) + + await httpExternal('https://example.test/search', { + query: { q: '80000', type: 'municipality' }, + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.test/search', + expect.objectContaining({ + query: { q: '80000', type: 'municipality' }, + credentials: 'omit', + retry: 0, + timeout: 5000, + }), + ) + }) + + it('permet de surcharger le timeout', async () => { + mockFetch.mockResolvedValueOnce(null) + + await httpExternal('https://example.test', { timeoutMs: 1000 }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.test', + expect.objectContaining({ timeout: 1000 }), + ) + }) + + it('propage l\'erreur reseau / timeout (throw)', async () => { + mockFetch.mockRejectedValueOnce(new Error('network down')) + + await expect(httpExternal('https://example.test')).rejects.toThrow('network down') + }) +}) diff --git a/frontend/shared/utils/__tests__/phone.test.ts b/frontend/shared/utils/__tests__/phone.test.ts index b18cb57..328d469 100644 --- a/frontend/shared/utils/__tests__/phone.test.ts +++ b/frontend/shared/utils/__tests__/phone.test.ts @@ -20,4 +20,27 @@ describe('formatPhoneFR', () => { it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => { expect(formatPhoneFR('123')).toBe('12 3') }) + + it('formate une saisie courte (<= 4 chiffres) sans planter', () => { + expect(formatPhoneFR('1')).toBe('1') + expect(formatPhoneFR('12')).toBe('12') + expect(formatPhoneFR('1234')).toBe('12 34') + }) + + it('strip les caracteres non numeriques (lettres, espaces, ponctuation)', () => { + expect(formatPhoneFR('abc')).toBe('') + expect(formatPhoneFR('Tel : 06.12')).toBe('06 12') + expect(formatPhoneFR(' 06 12 ')).toBe('06 12') + }) + + it('conserve l\'indicatif international (+33) sans le transformer', () => { + // Comportement fige : on retire seulement le `+`, on ne deduit pas le + // prefixe pays. Le `+33...` est donc groupe brut par paquets de 2. + expect(formatPhoneFR('+33612345678')).toBe('33 61 23 45 67 8') + }) + + it('groupe sans tronquer une saisie plus longue que 10 chiffres', () => { + // Aucune troncature silencieuse : on figure tous les chiffres groupes par 2. + expect(formatPhoneFR('061234567899')).toBe('06 12 34 56 78 99') + }) }) diff --git a/frontend/shared/utils/httpExternal.ts b/frontend/shared/utils/httpExternal.ts new file mode 100644 index 0000000..a332aa5 --- /dev/null +++ b/frontend/shared/utils/httpExternal.ts @@ -0,0 +1,40 @@ +import { $fetch } from 'ofetch' + +/** + * Options d'un appel HTTP externe. + */ +export interface HttpExternalOptions { + /** Parametres de query string (encodes par ofetch). */ + query?: Record + /** Timeout en millisecondes avant abandon (defaut 5000). */ + timeoutMs?: number +} + +/** + * Petit client HTTP pour les APIs PUBLIQUES EXTERNES (domaine tiers, hors `/api`). + * + * Pourquoi un helper dedie plutot que `useApi()` : `useApi()` est le client de + * l'API interne Starseed (baseURL `/api`, cookie JWT `credentials: 'include'`, + * parsing/erreurs Hydra, redirection `/login` sur 401, toasts i18n). Tout cela + * est inadapte — voire indesirable — pour un endpoint public externe comme la + * Base Adresse Nationale (`api-adresse.data.gouv.fr`). + * + * Ce helper est donc le SEUL point d'entree autorise pour un `$fetch` brut vers + * l'externe (cf. regle frontend n°4 : pas de `$fetch` eparpille dans les + * composants). Il : + * - cible une URL absolue (pas de baseURL `/api`) ; + * - n'envoie PAS le cookie de session (`credentials: 'omit'`) ; + * - ne retente pas (`retry: 0`) et applique un timeout ; + * - laisse remonter l'erreur (throw) — au consommateur de gerer le mode degrade. + */ +export async function httpExternal( + url: string, + opts: HttpExternalOptions = {}, +): Promise { + return $fetch(url, { + query: opts.query, + credentials: 'omit', + retry: 0, + timeout: opts.timeoutMs ?? 5000, + }) +}