fix(address) : recherche adresse BAN — retry apres erreur + garde 3 caracteres
Une erreur de l'API d'autocompletion (BAN) ne bascule plus le champ Adresse en saisie libre de maniere definitive : l'autocompletion reste montee et chaque frappe relance la recherche (le flag degrade etait verrouille a true sans jamais etre reinitialise). - Garde min. 3 caracteres avant l'appel BAN (evite le 400 de l'API). - Ville : repli saisie libre conserve mais recuperable (re-saisir le code postal repasse en select au succes). - Avertissement « service indisponible » emis une seule fois. - Tests Vitest : pas d'appel < 3 car., relance apres erreur, emission unique de l'evenement.
This commit is contained in:
@@ -87,8 +87,9 @@
|
|||||||
@update:model-value="onPostalCodeChange"
|
@update:model-value="onPostalCodeChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
|
||||||
degrade (service indisponible), bascule en saisie libre. -->
|
indisponible, bascule en saisie libre — recuperable : re-saisir le
|
||||||
|
code postal relance la recherche et repasse en select au succes. -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-if="!degraded"
|
v-if="!degraded"
|
||||||
:model-value="model.city"
|
:model-value="model.city"
|
||||||
@@ -115,11 +116,14 @@
|
|||||||
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||||
le col-span-2, le champ le remplit (w-full). -->
|
le col-span-2, le champ le remplit (w-full). -->
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
|
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
||||||
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
|
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
||||||
pas sa valeur liee, il n'afficherait rien en readonly). -->
|
sa valeur liee, il n'afficherait rien en readonly). Une erreur BAN
|
||||||
|
ne bascule PAS en saisie libre : l'autocompletion reste montee et
|
||||||
|
chaque frappe relance la recherche (l'utilisateur peut aussi taper
|
||||||
|
une rue librement). -->
|
||||||
<MalioInputAutocomplete
|
<MalioInputAutocomplete
|
||||||
v-if="!degraded && !readonly"
|
v-if="!readonly"
|
||||||
:model-value="model.street"
|
:model-value="model.street"
|
||||||
:options="addressOptions"
|
:options="addressOptions"
|
||||||
:loading="addressLoading"
|
:loading="addressLoading"
|
||||||
@@ -217,8 +221,12 @@ function onAddressTypeChange(value: string | number | null): void {
|
|||||||
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
|
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
|
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable :
|
||||||
|
// remis a false des qu'une recherche de ville aboutit). N'affecte plus le champ
|
||||||
|
// Adresse, qui reste en autocompletion et reessaie a chaque frappe.
|
||||||
const degraded = ref(false)
|
const degraded = ref(false)
|
||||||
|
// Avertissement « service indisponible » envoye au parent une seule fois.
|
||||||
|
let unavailableNotified = false
|
||||||
// Villes proposees par la BAN (alimentees a la saisie du code postal).
|
// Villes proposees par la BAN (alimentees a la saisie du code postal).
|
||||||
const banCityOptions = ref<RefOption[]>([])
|
const banCityOptions = ref<RefOption[]>([])
|
||||||
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
|
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
|
||||||
@@ -258,10 +266,10 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
|
|||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
|
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||||
function enterDegraded(): void {
|
function notifyUnavailable(): void {
|
||||||
if (!degraded.value) {
|
if (!unavailableNotified) {
|
||||||
degraded.value = true
|
unavailableNotified = true
|
||||||
emit('degraded')
|
emit('degraded')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,9 +278,6 @@ function enterDegraded(): void {
|
|||||||
async function onPostalCodeChange(value: string): Promise<void> {
|
async function onPostalCodeChange(value: string): Promise<void> {
|
||||||
update('postalCode', value)
|
update('postalCode', value)
|
||||||
|
|
||||||
if (degraded.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const digits = (value ?? '').replace(/\D/g, '')
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
if (digits.length < 5) {
|
if (digits.length < 5) {
|
||||||
return
|
return
|
||||||
@@ -280,15 +285,22 @@ async function onPostalCodeChange(value: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const suggestions = await autocomplete.searchCity(digits)
|
const suggestions = await autocomplete.searchCity(digits)
|
||||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||||
|
// Service repondu : on (re)passe la Ville en select assiste.
|
||||||
|
degraded.value = false
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
enterDegraded()
|
// BAN indispo : Ville en saisie libre (recuperable au prochain essai).
|
||||||
|
degraded.value = true
|
||||||
|
notifyUnavailable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||||
async function onAddressSearch(query: string): Promise<void> {
|
async function onAddressSearch(query: string): Promise<void> {
|
||||||
if (degraded.value) {
|
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400)
|
||||||
|
// et on vide les suggestions devenues obsoletes.
|
||||||
|
if (query.trim().length < 3) {
|
||||||
|
banAddressOptions.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addressLoading.value = true
|
addressLoading.value = true
|
||||||
@@ -299,7 +311,10 @@ async function onAddressSearch(query: string): Promise<void> {
|
|||||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
enterDegraded()
|
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie
|
||||||
|
// (pas de bascule definitive — c'etait le bug). Avertissement une seule fois.
|
||||||
|
banAddressOptions.value = []
|
||||||
|
notifyUnavailable()
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
addressLoading.value = false
|
addressLoading.value = false
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { defineComponent, h, ref, computed } from 'vue'
|
import { defineComponent, h, ref, computed } from 'vue'
|
||||||
import { emptyAddress } from '~/modules/commercial/types/clientForm'
|
import { emptyAddress } from '~/modules/commercial/types/clientForm'
|
||||||
import ClientAddressBlock from '../ClientAddressBlock.vue'
|
import ClientAddressBlock from '../ClientAddressBlock.vue'
|
||||||
|
|
||||||
// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee.
|
// Mocks controlables du composable BAN (hoisted) : chaque test configure le
|
||||||
// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions
|
// comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes).
|
||||||
// vide » (remontage apres validation / edition d'une adresse existante).
|
// Par defaut ils renvoient undefined (aucune suggestion) — etat « adresse
|
||||||
|
// persistee mais liste vide » couvert par les tests d'affichage.
|
||||||
|
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||||
|
searchCityMock: vi.fn(),
|
||||||
|
searchAddressMock: vi.fn(),
|
||||||
|
}))
|
||||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||||
useAddressAutocomplete: () => ({
|
useAddressAutocomplete: () => ({
|
||||||
searchCity: vi.fn(),
|
searchCity: searchCityMock,
|
||||||
searchAddress: vi.fn(),
|
searchAddress: searchAddressMock,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -130,3 +135,57 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
|||||||
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
|
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchAddressMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||||
|
const wrapper = mountBlock(null)
|
||||||
|
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||||
|
|
||||||
|
auto.vm.$emit('search', 'ab')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||||
|
searchAddressMock
|
||||||
|
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapper = mountBlock(null)
|
||||||
|
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||||
|
|
||||||
|
// 1er essai -> erreur BAN.
|
||||||
|
auto.vm.$emit('search', 'boulevard du port')
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchAddressMock).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// 2e essai -> DOIT relancer l'appel (c'etait le bug : plus aucune recherche).
|
||||||
|
auto.vm.$emit('search', 'boulevard du porte')
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
// L'autocompletion reste montee (aucune bascule en saisie libre).
|
||||||
|
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
|
||||||
|
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||||
|
|
||||||
|
const wrapper = mountBlock(null)
|
||||||
|
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||||
|
|
||||||
|
auto.vm.$emit('search', 'rue de la paix')
|
||||||
|
await flushPromises()
|
||||||
|
auto.vm.$emit('search', 'rue de la paixx')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user