5e15c1f69f
Auto Tag Develop / tag (push) Successful in 11s
Lot de retours métier **ERP-193** (« Fix tous les retours starseed »), transverse aux 4 répertoires (clients, fournisseurs, prestataires, transporteurs).
## Contenu
- **Pagination** : défaut à 25 items/page sur les 4 répertoires.
- **Libellé** : colonne « Dernière activité » → « Dernière modification ».
- **Consultation** : masquage des onglets vides (coquilles « à venir » + onglets de données sans donnée).
- **Chiffre d'affaires** : plafonné à 999 999 999 999,99 (clamp front + `Assert\LessThanOrEqual` back).
- **Date de création** : interdiction des dates futures (`:max` MalioDate + `Assert\LessThanOrEqual('today')` back).
- **Caractères spéciaux** : blocage des caractères parasites (`²³§~#|…`) dans les champs texte via une allow-list par profil (nom de personne / texte libre / adresse / code alphanumérique) — filtrage front à la frappe + `Assert\Regex` back autoritaire. Email/IBAN/BIC/TVA conservent leurs validateurs de format.
- **UI** : champs en consultation et onglets validés grisés (`readonly` → `disabled`).
- **UI** : boutons « Archiver » en rouge (variant `danger`).
## Tests
- Back : nouveaux tests RG (plafond CA, dates futures, caractères spéciaux) + garde-fou contraintes — suite complète verte (813 tests).
- Front : nouveaux tests unitaires (sanitizers, helpers date/montant) — 615 tests verts, eslint clean.
---------
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #139
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { defineComponent, h, ref, computed } from 'vue'
|
|
import { emptyAddress } from '~/modules/commercial/types/clientForm'
|
|
import ClientAddressBlock from '../ClientAddressBlock.vue'
|
|
|
|
// Mocks controlables du composable BAN (hoisted) : chaque test configure le
|
|
// comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes).
|
|
// 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', () => ({
|
|
useAddressAutocomplete: () => ({
|
|
searchCity: searchCityMock,
|
|
searchAddress: searchAddressMock,
|
|
}),
|
|
}))
|
|
|
|
// 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 },
|
|
allowCreate: { 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')
|
|
})
|
|
|
|
// ERP-119 : saisie manuelle possible quand la BAN ne trouve rien -> allow-create
|
|
// (sans cette prop, MalioInputAutocomplete efface le texte non selectionne au blur).
|
|
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
|
const wrapper = mountBlock(null)
|
|
|
|
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Stub MalioInputText qui re-expose `label` + `error` recus : permet de cibler
|
|
* un champ par son libelle et de verifier l'erreur 422 propagee (ERP-101).
|
|
*/
|
|
const MalioInputTextProbe = defineComponent({
|
|
name: 'MalioInputTextProbe',
|
|
props: {
|
|
modelValue: { type: [String, Number, null], default: undefined },
|
|
error: { type: String, default: '' },
|
|
label: { type: String, default: '' },
|
|
readonly: { type: Boolean, default: false },
|
|
},
|
|
setup(props) {
|
|
return () => h('div', {
|
|
'data-testid': 'addr-text',
|
|
'data-label': props.label,
|
|
'data-error': props.error,
|
|
})
|
|
},
|
|
})
|
|
|
|
describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
|
function mountWithErrors(errors: Record<string, string>) {
|
|
return mount(ClientAddressBlock, {
|
|
props: {
|
|
modelValue: emptyAddress(),
|
|
title: 'Adresse',
|
|
categoryOptions: [],
|
|
siteOptions: [],
|
|
contactOptions: [],
|
|
countryOptions: [],
|
|
errors,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
MalioButtonIcon: true,
|
|
MalioCheckbox: true,
|
|
MalioSelect: true,
|
|
MalioSelectCheckbox: true,
|
|
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
|
MalioInputText: MalioInputTextProbe,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
it('affiche l\'erreur serveur sur le champ code postal via la prop errors', () => {
|
|
const wrapper = mountWithErrors({ postalCode: 'Code postal invalide.' })
|
|
|
|
const field = wrapper.findAll('[data-testid="addr-text"]').find(
|
|
el => el.attributes('data-label') === 'commercial.clients.form.address.postalCode',
|
|
)
|
|
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
|
|
})
|
|
|
|
// ERP-119 : type d'adresse (propertyPath back `isProspect`), sites et
|
|
// categories sont obligatoires ; leurs violations 422 doivent s'afficher sous
|
|
// le champ correspondant (bindings :error de ClientAddressBlock).
|
|
it('affiche l\'erreur serveur sur type d\'adresse (propertyPath isProspect)', () => {
|
|
const wrapper = mountWithErrors({ isProspect: 'Le type d\'adresse est obligatoire.' })
|
|
|
|
const field = wrapper.findAll('malio-select-stub').find(
|
|
el => el.attributes('label') === 'commercial.clients.form.address.addressType',
|
|
)
|
|
expect(field?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
|
|
})
|
|
|
|
it('affiche les erreurs serveur sur sites et categories', () => {
|
|
const wrapper = mountWithErrors({
|
|
sites: 'Au moins un site est obligatoire.',
|
|
categories: 'Au moins une catégorie est obligatoire.',
|
|
})
|
|
|
|
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
|
const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.sites')
|
|
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.categories')
|
|
|
|
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
|
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`,
|
|
* pour piloter le champ Code postal et observer le brouillon emis.
|
|
*/
|
|
const MalioInputTextEmitter = defineComponent({
|
|
name: 'MalioInputTextEmitter',
|
|
props: {
|
|
modelValue: { type: [String, Number, null], default: undefined },
|
|
label: { type: String, default: '' },
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props) {
|
|
return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label })
|
|
},
|
|
})
|
|
|
|
describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => {
|
|
beforeEach(() => {
|
|
searchCityMock.mockReset()
|
|
searchCityMock.mockResolvedValue([])
|
|
})
|
|
|
|
function mountFilled() {
|
|
return mount(ClientAddressBlock, {
|
|
props: {
|
|
modelValue: {
|
|
...emptyAddress(),
|
|
postalCode: '75001',
|
|
city: 'Paris',
|
|
street: '8 Boulevard du Port',
|
|
streetComplement: 'Bat A',
|
|
},
|
|
title: 'Adresse',
|
|
categoryOptions: [],
|
|
siteOptions: [],
|
|
contactOptions: [],
|
|
countryOptions: [],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
MalioButtonIcon: true,
|
|
MalioCheckbox: true,
|
|
MalioSelect: true,
|
|
MalioSelectCheckbox: true,
|
|
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
|
MalioInputText: MalioInputTextEmitter,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
function postalCodeField(wrapper: ReturnType<typeof mountFilled>) {
|
|
return wrapper.findAllComponents(MalioInputTextEmitter).find(
|
|
c => c.props('label') === 'commercial.clients.form.address.postalCode',
|
|
)
|
|
}
|
|
|
|
it('vide ville, adresse et complement quand le CP complet change', async () => {
|
|
const wrapper = mountFilled()
|
|
|
|
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000')
|
|
await flushPromises()
|
|
|
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
|
expect(last.postalCode).toBe('33000')
|
|
expect(last.city).toBeNull()
|
|
expect(last.street).toBeNull()
|
|
expect(last.streetComplement).toBeNull()
|
|
})
|
|
|
|
it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => {
|
|
const wrapper = mountFilled()
|
|
|
|
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500')
|
|
await flushPromises()
|
|
|
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
|
expect(last.postalCode).toBe('7500')
|
|
expect(last.city).toBe('Paris')
|
|
expect(last.street).toBe('8 Boulevard du Port')
|
|
expect(last.streetComplement).toBe('Bat A')
|
|
})
|
|
|
|
it('ne vide pas les champs si le CP complet est identique', async () => {
|
|
const wrapper = mountFilled()
|
|
|
|
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001')
|
|
await flushPromises()
|
|
|
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
|
expect(last.city).toBe('Paris')
|
|
expect(last.street).toBe('8 Boulevard du Port')
|
|
expect(last.streetComplement).toBe('Bat A')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`,
|
|
* pour piloter le select Ville et observer le brouillon emis.
|
|
*/
|
|
const MalioSelectEmitter = defineComponent({
|
|
name: 'MalioSelectEmitter',
|
|
props: {
|
|
modelValue: { type: [String, Number, null], default: undefined },
|
|
label: { type: String, default: '' },
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props) {
|
|
return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label })
|
|
},
|
|
})
|
|
|
|
describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => {
|
|
beforeEach(() => {
|
|
searchCityMock.mockReset()
|
|
searchCityMock.mockResolvedValue([])
|
|
})
|
|
|
|
function mountFilled() {
|
|
return mount(ClientAddressBlock, {
|
|
props: {
|
|
modelValue: {
|
|
...emptyAddress(),
|
|
postalCode: '75001',
|
|
city: 'Paris',
|
|
street: '8 Boulevard du Port',
|
|
streetComplement: 'Bat A',
|
|
},
|
|
title: 'Adresse',
|
|
categoryOptions: [],
|
|
siteOptions: [],
|
|
contactOptions: [],
|
|
countryOptions: [],
|
|
},
|
|
global: {
|
|
stubs: {
|
|
MalioButtonIcon: true,
|
|
MalioCheckbox: true,
|
|
MalioSelectCheckbox: true,
|
|
MalioInputText: true,
|
|
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
|
MalioSelect: MalioSelectEmitter,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
function cityField(wrapper: ReturnType<typeof mountFilled>) {
|
|
return wrapper.findAllComponents(MalioSelectEmitter).find(
|
|
c => c.props('label') === 'commercial.clients.form.address.city',
|
|
)
|
|
}
|
|
|
|
it('vide adresse et complement quand la ville change', async () => {
|
|
const wrapper = mountFilled()
|
|
|
|
cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon')
|
|
await flushPromises()
|
|
|
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
|
expect(last.city).toBe('Lyon')
|
|
expect(last.street).toBeNull()
|
|
expect(last.streetComplement).toBeNull()
|
|
})
|
|
|
|
it('ne vide pas si la ville selectionnee est identique', async () => {
|
|
const wrapper = mountFilled()
|
|
|
|
cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris')
|
|
await flushPromises()
|
|
|
|
// Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte.
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|