import { describe, expect, it } from 'vitest' import { buildAccountingPayload, buildAddressPayload, buildContactPayload, buildInformationPayload, buildMainPayload, buildRibPayload, mapAccountingFormDraft, mapInformationDraft, mapMainDraft, resolveTabEditability, type AccountingFormDraft, type InformationFormDraft, type MainFormDraft, } from '../clientEdit' import type { ClientDetail } from '../clientConsultation' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' // ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ───── function mainDraft(overrides: Partial = {}): MainFormDraft { return { companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr', phonePrimary: '05 49 11 22 33', phoneSecondary: null, hasSecondaryPhone: false, categoryIris: ['/api/categories/1'], relationType: null, distributorIri: null, brokerIri: null, triageService: false, ...overrides, } } function informationDraft(overrides: Partial = {}): InformationFormDraft { return { description: 'desc', competitors: 'concurrents', foundedAt: '2010-05-01', employeesCount: '42', revenueAmount: '1000000', profitAmount: '50000', directorName: 'PDG', ...overrides, } } function accountingDraft(overrides: Partial = {}): AccountingFormDraft { return { siren: '123456789', accountNumber: 'C-001', nTva: 'FR123', tvaModeIri: '/api/tva_modes/1', paymentDelayIri: '/api/payment_delays/1', paymentTypeIri: '/api/payment_types/1', bankIri: '/api/banks/1', ...overrides, } } // Champs de chaque groupe de serialisation (miroir back ClientProcessor). const MAIN_KEYS = [ 'companyName', 'firstName', 'lastName', 'email', 'phonePrimary', 'phoneSecondary', 'categories', 'distributor', 'broker', 'triageService', ] const INFORMATION_KEYS = [ 'description', 'competitors', 'foundedAt', 'employeesCount', 'revenueAmount', 'profitAmount', 'directorName', ] const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank'] describe('buildMainPayload — scoping strict groupe client:write:main', () => { it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => { expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort()) }) it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => { const payload = buildMainPayload(mainDraft({ relationType: 'distributeur', distributorIri: '/api/clients/9', brokerIri: '/api/clients/7', })) expect(payload.distributor).toBe('/api/clients/9') expect(payload.broker).toBeNull() }) it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => { const payload = buildMainPayload(mainDraft({ relationType: 'courtier', distributorIri: '/api/clients/9', brokerIri: '/api/clients/7', })) expect(payload.broker).toBe('/api/clients/7') expect(payload.distributor).toBeNull() }) it('sans relation : distributor et broker a null', () => { const payload = buildMainPayload(mainDraft({ relationType: null })) expect(payload.distributor).toBeNull() expect(payload.broker).toBeNull() }) it('telephone secondaire non revele : envoie null meme si une valeur traine', () => { const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' })) expect(payload.phoneSecondary).toBeNull() }) }) describe('buildInformationPayload — scoping strict groupe client:write:information', () => { it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => { expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort()) }) it('convertit employeesCount en nombre et vide -> null', () => { expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42) expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull() expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull() }) it('chaines vides normalisees en null', () => { const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' })) expect(payload.description).toBeNull() expect(payload.directorName).toBeNull() }) }) describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => { it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => { expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort()) }) it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => { expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1') expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull() }) }) describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { it('contact : telephone secondaire ignore si non revele', () => { const contact: ContactFormDraft = { id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B', jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000', email: null, hasSecondaryPhone: false, } expect(buildContactPayload(contact).phoneSecondary).toBeNull() }) it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => { const address: AddressFormDraft = { id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France', postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null, categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], billingEmail: 'facturation@acme.fr', } expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr') expect(buildAddressPayload(address, false).billingEmail).toBeNull() }) it('rib : label / bic / iban transmis tels quels', () => { const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' } expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }) }) }) describe('mapMainDraft — pre-remplissage bloc principal', () => { it('formate les telephones, resout la relation et extrait les IRI', () => { const client = { '@id': '/api/clients/1', id: 1, companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr', phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true, categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }], distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' }, } as ClientDetail const draft = mapMainDraft(client) expect(draft.phonePrimary).toBe('05 49 11 22 33') expect(draft.phoneSecondary).toBe('06 00 00 00 00') expect(draft.hasSecondaryPhone).toBe(true) expect(draft.categoryIris).toEqual(['/api/categories/1']) expect(draft.relationType).toBe('distributeur') expect(draft.distributorIri).toBe('/api/clients/9') expect(draft.brokerIri).toBeNull() expect(draft.triageService).toBe(true) }) it('gere les cles omises (skip_null_values) sans planter', () => { const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail) expect(draft.companyName).toBeNull() expect(draft.hasSecondaryPhone).toBe(false) expect(draft.categoryIris).toEqual([]) expect(draft.relationType).toBeNull() expect(draft.triageService).toBe(false) }) }) describe('mapInformationDraft — pre-remplissage onglet Information', () => { it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => { const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1, foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000', } as ClientDetail) expect(draft.foundedAt).toBe('2010-05-01') expect(draft.employeesCount).toBe('42') expect(draft.revenueAmount).toBe('1000000') }) it('cles omises -> null', () => { const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail) expect(draft.foundedAt).toBeNull() expect(draft.employeesCount).toBeNull() expect(draft.description).toBeNull() }) }) describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => { it('extrait les scalaires et les IRI des referentiels embarques', () => { const draft = mapAccountingFormDraft({ '@id': '/api/clients/1', id: 1, siren: '123456789', accountNumber: 'C-001', nTva: 'FR123', tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' }, paymentType: '/api/payment_types/3', } as ClientDetail) expect(draft.siren).toBe('123456789') expect(draft.tvaModeIri).toBe('/api/tva_modes/2') expect(draft.paymentTypeIri).toBe('/api/payment_types/3') expect(draft.bankIri).toBeNull() }) }) describe('resolveTabEditability — gating par role (matrice § 2.7)', () => { it('Admin : tout editable', () => { expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true })) .toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true }) }) it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => { expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false })) .toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false }) }) it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => { expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true })) .toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true }) }) it('Sans permission d\'edition : rien d\'editable', () => { expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false })) .toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false }) }) })