feat(front) : util httpExternal + autocomplete adresse BAN (ERP-66)

- httpExternal : client dedie aux API publiques externes (URL absolue,
  sans cookie de session, timeout), seul point d'entree autorise pour un
  $fetch externe (regle frontend n°4).
- useAddressAutocomplete : implementation BAN (api-adresse.data.gouv.fr),
  recherche ville (type=municipality) et adresse, mapping GeoJSON, throw
  en cas d'erreur/timeout (mode degrade cote composant). La recherche
  d'adresse n'impose pas type=housenumber (sinon 0 resultat tant qu'aucun
  numero n'est saisi) — spec-front mise a jour en consequence.
- Tests Vitest : httpExternal, useAddressAutocomplete, et cas limites
  supplementaires pour formatPhoneFR.
This commit is contained in:
2026-06-03 13:29:45 +02:00
parent 1961bc62c8
commit 8376236a3c
6 changed files with 336 additions and 27 deletions
@@ -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<string, string>
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,
)
})
})
})