From 912280d24e59a0428bde4265d746c0945cac7c41 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 14:02:14 +0000 Subject: [PATCH] feat(front) : util httpExternal + autocomplete adresse BAN (ERP-66) (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ERP-66 — Utilitaires adresse/téléphone + autocomplétion BAN ### feat - **httpExternal** : client dédié aux API publiques externes (URL absolue, sans cookie de session, timeout). Seul point d'entrée autorisé pour un `$fetch` externe (règle frontend n°4). - **useAddressAutocomplete** : implémentation BAN (api-adresse.data.gouv.fr) — recherche ville (`type=municipality`) et adresse, mapping GeoJSON, throw en cas d'erreur/timeout (mode dégradé côté composant). La recherche d'adresse n'impose **pas** `type=housenumber` (sinon 0 résultat tant qu'aucun numéro n'est saisi) — spec-front mise à jour. - Tests Vitest : httpExternal, useAddressAutocomplete, cas limites `formatPhoneFR`. ### fix - **ClientAddressBlock** : la rue courante est toujours réinjectée dans les options de `MalioInputAutocomplete` (computed, miroir de `cityOptions`). Corrige le champ Adresse qui se vidait après validation / à l'édition d'une adresse existante (valeur pourtant persistée). Test de montage ajouté. - **useClientReferentials** : libellé des sites = numéro de département (2 premiers chiffres du code postal, déjà exposé par `/sites`) au lieu du nom. ### Vérifs - ESLint ✅ · Vitest 196/196 ✅ - Changements 100% frontend (+ doc spec). Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/52 Co-authored-by: tristan Co-committed-by: tristan --- docs/specs/M1-clients/spec-front.md | 3 +- frontend/i18n/locales/fr.json | 10 +- .../components/ClientAddressBlock.vue | 19 ++- .../components/TabPlaceholderBlank.vue | 14 -- .../__tests__/ClientAddressBlock.spec.ts | 76 ++++++++++ .../composables/useClientReferentials.ts | 6 +- .../commercial/pages/clients/[id]/edit.vue | 33 +++-- .../commercial/pages/clients/[id]/index.vue | 51 ++++--- .../modules/commercial/pages/clients/new.vue | 15 +- frontend/public/coming-soon.gif | Bin 0 -> 1557856 bytes .../components/ui/ComingSoonPlaceholder.vue | 51 +++++++ .../__tests__/useAddressAutocomplete.test.ts | 132 ++++++++++++++++++ .../composables/useAddressAutocomplete.ts | 109 +++++++++++---- .../utils/__tests__/httpExternal.test.ts | 56 ++++++++ frontend/shared/utils/__tests__/phone.test.ts | 23 +++ frontend/shared/utils/httpExternal.ts | 40 ++++++ .../Domain/Entity/ClientAddress.php | 2 + .../Commercial/Api/ClientAddressTest.php | 29 +++- .../Api/ClientSubResourceApiTest.php | 8 +- 19 files changed, 590 insertions(+), 87 deletions(-) delete mode 100644 frontend/modules/commercial/components/TabPlaceholderBlank.vue create mode 100644 frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts create mode 100644 frontend/public/coming-soon.gif create mode 100644 frontend/shared/components/ui/ComingSoonPlaceholder.vue create mode 100644 frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts create mode 100644 frontend/shared/utils/__tests__/httpExternal.test.ts create mode 100644 frontend/shared/utils/httpExternal.ts diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md index b476095..88a19ab 100644 --- a/docs/specs/M1-clients/spec-front.md +++ b/docs/specs/M1-clients/spec-front.md @@ -258,7 +258,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/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 4fa1c99..c09f4e9 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -10,7 +10,11 @@ "confirm": "Confirmer", "yes": "Oui", "no": "Non", - "actions": "Actions" + "actions": "Actions", + "comingSoon": { + "title": "En cours de dev", + "subtitle": "Cette fonctionnalité arrive bientôt." + } }, "sidebar": { "administration": { @@ -95,8 +99,6 @@ "back": "Retour au répertoire", "loading": "Chargement du client…", "notFound": "Client introuvable.", - "emptyContacts": "Aucun contact enregistré.", - "emptyAddresses": "Aucune adresse enregistrée.", "confirmArchive": { "title": "Archiver le client", "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" @@ -111,8 +113,6 @@ "back": "Retour au répertoire", "loading": "Chargement du client…", "notFound": "Client introuvable.", - "emptyContacts": "Aucun contact enregistré.", - "emptyAddresses": "Aucune adresse enregistrée.", "save": "Valider" }, "validation": { diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index 3539dbf..d19ad95 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -201,7 +201,8 @@ const model = computed(() => props.modelValue) const degraded = ref(false) // Villes proposees par la BAN (alimentees a la saisie du code postal). const banCityOptions = ref([]) -const addressOptions = ref([]) +// Adresses proposees par la BAN (alimentees a la saisie d'adresse). +const banAddressOptions = ref([]) // Options ville effectives : on garantit que la ville courante figure toujours // dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options) @@ -214,6 +215,20 @@ const cityOptions = computed(() => { } return banCityOptions.value }) + +// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit +// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout +// l'affichage depuis ses options) laisse le champ VIDE des que la liste de +// suggestions BAN est vide — typiquement juste apres validation (remontage) ou +// a l'edition d'une adresse existante (1.12), alors que la valeur est bien +// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee. +const addressOptions = computed(() => { + const current = props.modelValue.street + if (current && !banAddressOptions.value.some(o => o.value === current)) { + return [{ value: current, label: current }, ...banAddressOptions.value] + } + return banAddressOptions.value +}) const addressLoading = ref(false) // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. let lastAddressSuggestions: AddressSuggestion[] = [] @@ -280,7 +295,7 @@ async function onAddressSearch(query: string): Promise { const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined const suggestions = await autocomplete.searchAddress(query, postalCode) lastAddressSuggestions = suggestions - addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) + banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) } catch { enterDegraded() diff --git a/frontend/modules/commercial/components/TabPlaceholderBlank.vue b/frontend/modules/commercial/components/TabPlaceholderBlank.vue deleted file mode 100644 index 5375cb6..0000000 --- a/frontend/modules/commercial/components/TabPlaceholderBlank.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts new file mode 100644 index 0000000..1ec6587 --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyAddress } from '~/modules/commercial/types/clientForm' +import ClientAddressBlock from '../ClientAddressBlock.vue' + +// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee. +// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions +// vide » (remontage apres validation / edition d'une adresse existante). +vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ + useAddressAutocomplete: () => ({ + searchCity: vi.fn(), + searchAddress: vi.fn(), + }), +})) + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +// Stub de MalioInputAutocomplete : expose les `value` des options recues, pour +// verifier que la rue courante figure bien dans la liste (sinon le composant +// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide). +const MalioInputAutocompleteStub = defineComponent({ + name: 'MalioInputAutocomplete', + props: { + modelValue: { type: [String, Number, null], default: undefined }, + options: { type: Array as () => { value: string | number, label: string }[], default: () => [] }, + loading: { type: Boolean, default: false }, + minSearchLength: { type: Number, default: 0 }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + }, + emits: ['update:modelValue', 'search', 'select'], + setup(props) { + return () => h('div', { + 'data-testid': 'addr-autocomplete', + 'data-options': JSON.stringify(props.options.map(o => o.value)), + }) + }, +}) + +function mountBlock(street: string | null) { + return mount(ClientAddressBlock, { + props: { + modelValue: { ...emptyAddress(), street }, + title: 'Adresse', + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioCheckbox: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + }, + }, + }) +} + +describe('ClientAddressBlock — affichage de l\'adresse persistee', () => { + it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => { + const wrapper = mountBlock('8 Boulevard du Port') + + const el = wrapper.find('[data-testid="addr-autocomplete"]') + const values = JSON.parse(el.attributes('data-options') ?? '[]') + + expect(values).toContain('8 Boulevard du Port') + }) +}) diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts index 0f5bb8f..6721ee2 100644 --- a/frontend/modules/commercial/composables/useClientReferentials.ts +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -45,6 +45,7 @@ interface CategoryMember extends HydraMember { interface SiteMember extends HydraMember { name: string + postalCode: string } interface ReferentialMember extends HydraMember { @@ -101,7 +102,10 @@ export function useClientReferentials() { fetchAll('/categories') .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), fetchAll('/sites') - .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }), + // Libelle = numero de departement (2 premiers chiffres du code + // postal du site), ex: 86100 -> « 86 ». Le code postal est deja + // expose par /sites (groupe site:read) — aucune colonne a ajouter. + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), fetchAll('/tva_modes') .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), fetchAll('/payment_delays') diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index e1f64e5..06a2f77 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -179,9 +179,6 @@ @update:model-value="(v) => contacts[index] = v" @remove="askRemoveContact(index)" /> -

- {{ t('commercial.clients.edit.emptyContacts') }} -

-

- {{ t('commercial.clients.edit.emptyAddresses') }} -

-
+
-
+
- - - - + + + + @@ -495,6 +489,11 @@ function hydrate(detail: ClientDetail): void { contacts.value = (detail.contacts ?? []).map(mapContactToDraft) addresses.value = (detail.addresses ?? []).map(mapAddressToDraft) ribs.value = (detail.ribs ?? []).map(mapRibToDraft) + // Chaque bloc reste visible meme vide : si une collection est vide, on amorce + // un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*). + if (contacts.value.length === 0) contacts.value.push(emptyContact()) + if (addresses.value.length === 0) addresses.value.push(emptyAddress()) + if (ribs.value.length === 0) ribs.value.push(emptyRib()) // Charge les listes distributeur / courtier si une relation est deja posee. if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) @@ -694,6 +693,8 @@ function askRemoveContact(index: number): void { const removed = contacts.value[index] if (removed?.id != null) removedContactIds.value.push(removed.id) contacts.value.splice(index, 1) + // Garde au moins un bloc visible (cf. amorce a l'hydratation). + if (contacts.value.length === 0) contacts.value.push(emptyContact()) }) } @@ -742,7 +743,9 @@ const canValidateAddresses = computed(() => addresses.value.length > 0 && addresses.value.every((a) => { const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' - return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail) + return a.siteIris.length >= 1 + && a.categoryIris.length >= 1 + && (!isBillingEmailRequired(a) || filledBillingEmail) }), ) @@ -755,6 +758,8 @@ function askRemoveAddress(index: number): void { const removed = addresses.value[index] if (removed?.id != null) removedAddressIds.value.push(removed.id) addresses.value.splice(index, 1) + // Garde au moins un bloc visible (cf. amorce a l'hydratation). + if (addresses.value.length === 0) addresses.value.push(emptyAddress()) }) } @@ -833,6 +838,8 @@ function askRemoveRib(index: number): void { const removed = ribs.value[index] if (removed?.id != null) removedRibIds.value.push(removed.id) ribs.value.splice(index, 1) + // Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). + if (ribs.value.length === 0) ribs.value.push(emptyRib()) }) } diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue index 52ea3cd..1951700 100644 --- a/frontend/modules/commercial/pages/clients/[id]/index.vue +++ b/frontend/modules/commercial/pages/clients/[id]/index.vue @@ -159,9 +159,6 @@ :title="t('commercial.clients.form.contact.title', { n: index + 1 })" readonly /> -

- {{ t('commercial.clients.consultation.emptyContacts') }} -

@@ -174,14 +171,11 @@ :model-value="view.draft" :title="t('commercial.clients.form.address.title', { n: index + 1 })" :category-options="view.categoryOptions" - :site-options="view.siteOptions" + :site-options="allSiteOptions" :contact-options="contactOptions" :country-options="countryOptions" readonly /> -

- {{ t('commercial.clients.consultation.emptyAddresses') }} -

@@ -189,7 +183,7 @@ @@ -320,6 +314,7 @@ import { type SelectOption, } from '~/modules/commercial/utils/clientConsultation' import { formatPhoneFR } from '~/shared/utils/phone' +import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm' // Masques d'affichage (purement visuels, la donnee reste celle du serveur). const PHONE_MASK = '## ## ## ## ##' @@ -330,6 +325,7 @@ const route = useRoute() const router = useRouter() const toast = useToast() const { can, canAny } = usePermissions() +const authStore = useAuthStore() // Gating de la route : la consultation exige `view`. Usine (sans view) est // redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7. @@ -372,10 +368,21 @@ const information = computed(() => ({ directorName: client.value?.directorName ?? null, })) -const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft)) +// Chaque bloc reste visible meme vide en consultation : si la collection est +// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »). +const contacts = computed(() => { + const list = (client.value?.contacts ?? []).map(mapContactToDraft) + return list.length ? list : [emptyContact()] +}) // Vue par adresse : brouillon + options (sites/categories) propres a l'adresse. -const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView)) -const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft)) +const addressViews = computed(() => { + const views = (client.value?.addresses ?? []).map(mapAddressView) + return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] +}) +const ribs = computed(() => { + const list = (client.value?.ribs ?? []).map(mapRibToDraft) + return list.length ? list : [emptyRib()] +}) // Draft comptable (tout null si l'utilisateur n'a pas accounting.view). const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) @@ -385,6 +392,18 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories)) const contactOptions = computed(() => contactOptionsOf(client.value?.contacts)) +// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read — donc +// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero +// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS +// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non +// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris). +const allSiteOptions = computed(() => + (authStore.user?.sites ?? []).map(s => ({ + value: `/api/sites/${s.id}`, + label: (s.postalCode ?? '').slice(0, 2), + })), +) + const relationOptions = computed(() => [ { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index f651225..7bbdbe6 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -233,7 +233,7 @@ + + 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, + }) +} diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index c359088..25d779d 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -177,12 +177,14 @@ class ClientAddress implements TimestampableInterface, BlamableInterface #[Groups(['client_address:read', 'client_address:write'])] private Collection $contacts; + // Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse). // RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] #[Groups(['client_address:read', 'client_address:write'])] private Collection $categories; diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index 0c59207..93eb41c 100644 --- a/tests/Module/Commercial/Api/ClientAddressTest.php +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -167,8 +167,9 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase public function testNonBillingAddressAcceptsEmptyBillingEmail(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedClient('Non Billing Empty Email'); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Non Billing Empty Email'); + $category = $this->createCategory('SECTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], @@ -179,6 +180,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], ], ]); @@ -286,6 +288,29 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(201); } + /** + * Spec-front § Adresse : au moins une categorie est obligatoire sur une + * adresse. POST sans categorie (mais avec site) -> 422. + */ + public function testAddressRequiresAtLeastOneCategory(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address No Cat'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + /** * Retourne l'IRI du premier site seede (fixtures Sites). */ diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index dd7538c..a05ac33 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -110,9 +110,10 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase public function testPostAddressNormalizesBillingEmail(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedClient('Address Host'); - $siteIri = $this->firstSiteIri(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Host'); + $siteIri = $this->firstSiteIri(); + $category = $this->createCategory('SECTEUR'); $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], @@ -123,6 +124,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$siteIri], + 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray();