/** * useAgendaEventPickerBilling.spec.js — A66 sub-sessão 1C-ii-a * * Cobre handlers de patient picker + billing items + 2 watchers * (form.commitment_id, form.insurance_plan_id). * * Mock estratégia: monta um composer fake + actions fake com refs/computeds * mínimos. Mock supabase.from('patients') pra loadPatients. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ref, computed } from 'vue'; // ── Mocks ───────────────────────────────────────────────────── let _supabaseSelectArgs = null; const _patientsResult = { data: [], error: null }; function makeQ() { // O loadPatients faz: from().select().order().limit() e DEPOIS adiciona // .eq() condicionalmente, finalizando com `await q`. Pra suportar isso, // o mock retorna o mesmo `q` em todos os métodos da chain e implementa // `then` (thenable) pra ser awaitable. const q = { select: (...args) => { _supabaseSelectArgs = args; return q; }, eq: () => q, order: () => q, limit: () => q, then: (resolve) => resolve(_patientsResult) }; return q; } vi.mock('@/lib/supabase/client', () => ({ supabase: { from: () => makeQ() } })); function resetPatientsResult({ data = [], error = null } = {}) { _patientsResult.data = data; _patientsResult.error = error; } const { useAgendaEventPickerBilling } = await import('../useAgendaEventPickerBilling.js'); // ── Helpers ─────────────────────────────────────────────────── function makeComposer(overrides = {}) { const form = ref({ commitment_id: null, paciente_id: null, paciente_nome: '', paciente_avatar: '', extra_fields: {}, price: null, insurance_plan_id: null, insurance_plan_service_id: null, insurance_value: null, insurance_guide_number: null, duracaoMin: 50, ...(overrides.formExtra || {}) }); return { form, billingType: ref('particular'), isEdit: ref(false), visible: ref(true), step: ref(1), requiresPatient: ref(true), allowBack: ref(true), ...overrides }; } function makeActions() { return { _restoringConvenio: ref(false), samePatientConflict: ref(null) }; } function setup(overrides = {}, propsOverrides = {}) { _supabaseSelectArgs = null; // NÃO resetamos _patientsResult aqui pra permitir que o teste pré-popule. // Cada `it` chama resetPatientsResult() explicitamente quando precisa. const composer = overrides.composer ?? makeComposer(); const actions = overrides.actions ?? makeActions(); const commitmentItems = overrides.commitmentItems ?? ref([]); const servicePickerSel = overrides.servicePickerSel ?? ref(null); const selectedPlanService = overrides.selectedPlanService ?? ref(null); const services = overrides.services ?? ref([]); const loadServices = vi.fn().mockResolvedValue(); const getDefaultPrice = overrides.getDefaultPrice ?? vi.fn(() => null); const planServices = overrides.planServices ?? computed(() => []); const loadActiveDiscount = overrides.loadActiveDiscount ?? vi.fn().mockResolvedValue(null); const _csLoadItems = overrides._csLoadItems ?? vi.fn().mockResolvedValue([]); const _csLoadItemsOrTemplate = overrides._csLoadItemsOrTemplate ?? vi.fn().mockResolvedValue([]); const isDynamic = overrides.isDynamic ?? computed(() => false); const props = { ownerId: 'owner-1', tenantId: 'tenant-1', eventRow: null, agendaSettings: { session_duration_min: 50 }, restrictPatientsToOwner: false, patientScopeOwnerId: null, newPatientRoute: '', ...propsOverrides }; const result = useAgendaEventPickerBilling({ composer, actions, commitmentItems, servicePickerSel, selectedPlanService, services, loadServices, getDefaultPrice, planServices, loadActiveDiscount, _csLoadItems, _csLoadItemsOrTemplate, isDynamic, props }); return { composer, actions, commitmentItems, servicePickerSel, selectedPlanService, services, loadServices, getDefaultPrice, planServices, loadActiveDiscount, _csLoadItems, _csLoadItemsOrTemplate, isDynamic, props, ...result }; } // ════════════════════════════════════════════════════════════════════ describe('addItem', () => { it('no-op sem service.id', async () => { const { commitmentItems, addItem } = setup(); await addItem(null); await addItem({}); expect(commitmentItems.value).toEqual([]); }); it('adiciona item novo com final_price calculado', async () => { const { commitmentItems, addItem } = setup(); await addItem({ id: 's-1', name: 'Sessão', price: 100 }); expect(commitmentItems.value).toHaveLength(1); expect(commitmentItems.value[0]).toMatchObject({ service_id: 's-1', service_name: 'Sessão', quantity: 1, unit_price: 100, discount_pct: 0, discount_flat: 0, final_price: 100 }); }); it('incrementa quantity quando service_id já existe', async () => { const { commitmentItems, addItem } = setup(); await addItem({ id: 's-1', name: 'Sessão', price: 100 }); await addItem({ id: 's-1', name: 'Sessão', price: 100 }); await addItem({ id: 's-1', name: 'Sessão', price: 100 }); expect(commitmentItems.value).toHaveLength(1); expect(commitmentItems.value[0].quantity).toBe(3); expect(commitmentItems.value[0].final_price).toBe(300); }); it('aplica desconto ativo do paciente', async () => { const composer = makeComposer({ formExtra: { paciente_id: 'p-1' } }); const loadActiveDiscount = vi.fn().mockResolvedValue({ discount_pct: 10, discount_flat: 5 }); const { commitmentItems, addItem } = setup({ composer, loadActiveDiscount }); await addItem({ id: 's-1', name: 'X', price: 100 }); expect(loadActiveDiscount).toHaveBeenCalledWith('owner-1', 'p-1'); // 100 - 10% = 90 - 5 = 85 expect(commitmentItems.value[0].final_price).toBe(85); }); it('sem patient_id NÃO chama loadActiveDiscount', async () => { const { addItem, loadActiveDiscount } = setup(); await addItem({ id: 's-1', name: 'X', price: 100 }); expect(loadActiveDiscount).not.toHaveBeenCalled(); }); }); describe('removeItem', () => { it('remove item por índice', () => { const items = ref([ { service_id: 'a', quantity: 1, final_price: 100 }, { service_id: 'b', quantity: 1, final_price: 200 } ]); const { removeItem, commitmentItems } = setup({ commitmentItems: items }); removeItem(0); expect(commitmentItems.value).toHaveLength(1); expect(commitmentItems.value[0].service_id).toBe('b'); }); it('lista vazia em modo dynamic restaura duração padrão', () => { const composer = makeComposer({ formExtra: { duracaoMin: 30 } }); const isDynamic = computed(() => true); const items = ref([{ service_id: 'a', quantity: 1, final_price: 100 }]); const { removeItem } = setup({ composer, isDynamic, commitmentItems: items }); removeItem(0); expect(composer.form.value.duracaoMin).toBe(50); // session_duration_min default }); it('NÃO restaura duração se !isDynamic', () => { const composer = makeComposer({ formExtra: { duracaoMin: 30 } }); const items = ref([{ service_id: 'a', quantity: 1, final_price: 100 }]); const { removeItem } = setup({ composer, commitmentItems: items }); removeItem(0); expect(composer.form.value.duracaoMin).toBe(30); }); }); describe('onItemChange', () => { it('recalcula final_price baseado em quantity/discounts', () => { const { onItemChange } = setup(); const item = { unit_price: 100, quantity: 2, discount_pct: 10, discount_flat: 0, final_price: 0 }; onItemChange(item); expect(item.final_price).toBe(180); // 200 - 10% = 180 }); }); describe('onProcedureSelect', () => { it('seta plan_service_id e atualiza insurance_value', () => { const planServices = computed(() => [{ id: 'ps-1', value: 250.5 }]); const composer = makeComposer(); const { onProcedureSelect } = setup({ composer, planServices }); onProcedureSelect('ps-1'); expect(composer.form.value.insurance_plan_service_id).toBe('ps-1'); expect(composer.form.value.insurance_value).toBe(250.5); }); it('null limpa insurance_value', () => { const composer = makeComposer({ formExtra: { insurance_value: 100 } }); const { onProcedureSelect } = setup({ composer }); onProcedureSelect(null); expect(composer.form.value.insurance_plan_service_id).toBe(null); expect(composer.form.value.insurance_value).toBe(null); }); it('id desconhecido limpa insurance_value', () => { const planServices = computed(() => [{ id: 'ps-1', value: 250 }]); const composer = makeComposer({ formExtra: { insurance_value: 100 } }); const { onProcedureSelect } = setup({ composer, planServices }); onProcedureSelect('ps-x'); expect(composer.form.value.insurance_plan_service_id).toBe('ps-x'); expect(composer.form.value.insurance_value).toBe(null); }); }); describe('selectCommitment', () => { it('seta commitment_id, reseta extra_fields, vai pra step 2', () => { const composer = makeComposer(); const { selectCommitment } = setup({ composer }); selectCommitment({ id: 'c-1', name: 'X', fields: [{ key: 'idade' }, { key: 'peso' }] }); expect(composer.form.value.commitment_id).toBe('c-1'); expect(composer.form.value.extra_fields).toEqual({ idade: '', peso: '' }); expect(composer.step.value).toBe(2); }); it('no-op sem id', () => { const composer = makeComposer(); const { selectCommitment } = setup({ composer }); composer.step.value = 1; selectCommitment(null); selectCommitment({}); expect(composer.step.value).toBe(1); }); }); describe('goBack', () => { it('volta pra step 1 e limpa commitment + paciente', () => { const composer = makeComposer({ formExtra: { commitment_id: 'c-1', paciente_id: 'p-1', paciente_nome: 'Ana' } }); composer.step.value = 2; const { goBack } = setup({ composer }); goBack(); expect(composer.step.value).toBe(1); expect(composer.form.value.commitment_id).toBe(null); expect(composer.form.value.paciente_id).toBe(null); expect(composer.form.value.paciente_nome).toBe(''); }); it('no-op em edição', () => { const composer = makeComposer(); composer.isEdit.value = true; composer.step.value = 2; const { goBack } = setup({ composer }); goBack(); expect(composer.step.value).toBe(2); }); it('no-op com !allowBack', () => { const composer = makeComposer(); composer.allowBack.value = false; composer.step.value = 2; const { goBack } = setup({ composer }); goBack(); expect(composer.step.value).toBe(2); }); }); describe('selectPaciente / clearPaciente', () => { it('selectPaciente preenche form e fecha picker', () => { const composer = makeComposer(); const { selectPaciente, pacientePickerOpen } = setup({ composer }); pacientePickerOpen.value = true; selectPaciente({ id: 'p-1', nome: 'Ana', avatar_url: 'url' }); expect(composer.form.value.paciente_id).toBe('p-1'); expect(composer.form.value.paciente_nome).toBe('Ana'); expect(composer.form.value.paciente_avatar).toBe('url'); expect(pacientePickerOpen.value).toBe(false); }); it('selectPaciente no-op sem id', () => { const composer = makeComposer(); const { selectPaciente } = setup({ composer }); selectPaciente(null); selectPaciente({}); expect(composer.form.value.paciente_id).toBe(null); }); it('clearPaciente limpa form + samePatientConflict', () => { const composer = makeComposer({ formExtra: { paciente_id: 'p-1', paciente_nome: 'Ana', paciente_avatar: 'url' } }); const actions = makeActions(); actions.samePatientConflict.value = { id: 'evt-x' }; const { clearPaciente } = setup({ composer, actions }); clearPaciente(); expect(composer.form.value.paciente_id).toBe(null); expect(composer.form.value.paciente_nome).toBe(''); expect(actions.samePatientConflict.value).toBe(null); }); }); describe('openPacientePicker', () => { it('abre picker e dispara loadPatients', () => { const composer = makeComposer(); composer.requiresPatient.value = true; const { openPacientePicker, pacientePickerOpen } = setup({ composer }); openPacientePicker(); expect(pacientePickerOpen.value).toBe(true); }); it('no-op quando NÃO requiresPatient', () => { const composer = makeComposer(); composer.requiresPatient.value = false; const { openPacientePicker, pacientePickerOpen } = setup({ composer }); openPacientePicker(); expect(pacientePickerOpen.value).toBe(false); }); }); describe('clearPatientsCache', () => { it('zera patients, search e error', () => { const { clearPatientsCache, patients, pacienteSearch, pacientesError } = setup(); patients.value = [{ id: 'p-1' }]; pacienteSearch.value = 'foo'; pacientesError.value = 'err'; clearPatientsCache(); expect(patients.value).toEqual([]); expect(pacienteSearch.value).toBe(''); expect(pacientesError.value).toBe(''); }); }); describe('loadPatients', () => { it('faz fetch e mapeia resultado', async () => { const { loadPatients, patients } = setup(); resetPatientsResult({ data: [{ id: 'p-1', nome_completo: 'Ana', email_principal: 'a@x', telefone: '11', status: 'Ativo', avatar_url: 'u' }] }); await loadPatients(true); expect(patients.value).toHaveLength(1); expect(patients.value[0]).toMatchObject({ id: 'p-1', nome: 'Ana', email: 'a@x', telefone: '11' }); }); it('skip quando já tem cache e !force', async () => { const { loadPatients, patients } = setup(); patients.value = [{ id: 'cached' }]; await loadPatients(false); expect(patients.value).toEqual([{ id: 'cached' }]); }); it('error → setta pacientesError e zera lista', async () => { const { loadPatients, patients, pacientesError } = setup(); resetPatientsResult({ data: null, error: new Error('boom') }); await loadPatients(true); expect(patients.value).toEqual([]); expect(pacientesError.value).toBe('boom'); }); }); describe('applyDefaultPrice', () => { it('skip em billingType=particular', () => { const composer = makeComposer(); composer.billingType.value = 'particular'; const getDefaultPrice = vi.fn(() => 100); const { applyDefaultPrice } = setup({ composer, getDefaultPrice }); applyDefaultPrice(); expect(composer.form.value.price).toBe(null); }); it('skip em edição', () => { const composer = makeComposer(); composer.billingType.value = 'gratuito'; composer.isEdit.value = true; const getDefaultPrice = vi.fn(() => 100); const { applyDefaultPrice } = setup({ composer, getDefaultPrice }); applyDefaultPrice(); expect(composer.form.value.price).toBe(null); }); it('aplica em criação + billingType != particular', () => { const composer = makeComposer(); composer.billingType.value = 'gratuito'; const getDefaultPrice = vi.fn(() => 75); const { applyDefaultPrice } = setup({ composer, getDefaultPrice }); applyDefaultPrice(); expect(composer.form.value.price).toBe(75); }); }); describe('Watcher: form.commitment_id (auto-fill price)', () => { it('dispara em criação + visível: ensureServices + applyDefaultPrice', async () => { const composer = makeComposer(); // applyDefaultPrice skip se billingType=particular (default); usar 'gratuito' composer.billingType.value = 'gratuito'; const getDefaultPrice = vi.fn(() => 80); const { loadServices, composer: c } = setup({ composer, getDefaultPrice }); c.form.value.commitment_id = 'c-1'; await new Promise((r) => setTimeout(r, 0)); expect(loadServices).toHaveBeenCalled(); expect(c.form.value.price).toBe(80); }); it('NÃO dispara em edição', async () => { const composer = makeComposer(); composer.isEdit.value = true; const { loadServices } = setup({ composer }); composer.form.value.commitment_id = 'c-1'; await new Promise((r) => setTimeout(r, 0)); expect(loadServices).not.toHaveBeenCalled(); }); it('NÃO dispara quando dialog fechado (!visible)', async () => { const composer = makeComposer(); composer.visible.value = false; const { loadServices } = setup({ composer }); composer.form.value.commitment_id = 'c-1'; await new Promise((r) => setTimeout(r, 0)); expect(loadServices).not.toHaveBeenCalled(); }); }); describe('Watcher: form.insurance_plan_id', () => { it('seleciona convênio: limpa items + servicePickerSel', async () => { const composer = makeComposer(); const items = ref([{ service_id: 'a' }]); const sps = ref('svc-x'); const { commitmentItems, servicePickerSel } = setup({ composer, commitmentItems: items, servicePickerSel: sps }); composer.form.value.insurance_plan_id = 'plan-1'; await new Promise((r) => setTimeout(r, 0)); expect(commitmentItems.value).toEqual([]); expect(servicePickerSel.value).toBe(null); }); it('desmarca convênio: limpa insurance_value e guide', async () => { const composer = makeComposer({ formExtra: { insurance_value: 100, insurance_guide_number: 'X' } }); const { composer: c } = setup({ composer }); c.form.value.insurance_plan_id = 'plan-1'; await new Promise((r) => setTimeout(r, 0)); c.form.value.insurance_plan_id = null; await new Promise((r) => setTimeout(r, 0)); expect(c.form.value.insurance_value).toBe(null); expect(c.form.value.insurance_guide_number).toBe(null); }); it('ignorado quando _restoringConvenio', async () => { const composer = makeComposer(); const actions = makeActions(); actions._restoringConvenio.value = true; const items = ref([{ service_id: 'a' }]); const { commitmentItems } = setup({ composer, actions, commitmentItems: items }); composer.form.value.insurance_plan_id = 'plan-1'; await new Promise((r) => setTimeout(r, 0)); // Restoração ativa: não toca em commitmentItems expect(commitmentItems.value).toHaveLength(1); }); }); describe('_loadCommitmentItemsForEvent', () => { it('sem eventId nem ruleId: limpa items, billingType=particular', async () => { const composer = makeComposer(); const items = ref([{ service_id: 'a' }]); const { _loadCommitmentItemsForEvent, commitmentItems } = setup({ composer, commitmentItems: items }); await _loadCommitmentItemsForEvent(null); expect(commitmentItems.value).toEqual([]); expect(composer.billingType.value).toBe('particular'); }); it('eventRow com insurance_plan_id: aplica convenio', async () => { const composer = makeComposer(); const props = { ownerId: 'owner-1', tenantId: 't-1', agendaSettings: { session_duration_min: 50 }, eventRow: { insurance_plan_id: 'plan-1', insurance_guide_number: 'G-1', insurance_value: 200, insurance_plan_service_id: 'ps-1' } }; const { _loadCommitmentItemsForEvent } = setup({ composer }, props); await _loadCommitmentItemsForEvent('evt-1'); // Espera nextTick interno await new Promise((r) => setTimeout(r, 10)); expect(composer.billingType.value).toBe('convenio'); expect(composer.form.value.insurance_plan_id).toBe('plan-1'); expect(composer.form.value.insurance_value).toBe(200); }); it('com items carregados: billingType=particular', async () => { const composer = makeComposer(); const _csLoadItems = vi.fn().mockResolvedValue([{ service_id: 's-1', final_price: 100 }]); const { _loadCommitmentItemsForEvent, commitmentItems } = setup({ composer, _csLoadItems }); await _loadCommitmentItemsForEvent('evt-1'); expect(commitmentItems.value).toHaveLength(1); expect(composer.billingType.value).toBe('particular'); }); it('sem items: billingType=gratuito', async () => { const composer = makeComposer(); const _csLoadItems = vi.fn().mockResolvedValue([]); const { _loadCommitmentItemsForEvent } = setup({ composer, _csLoadItems }); await _loadCommitmentItemsForEvent('evt-1'); expect(composer.billingType.value).toBe('gratuito'); }); it('error: items=[], billingType=gratuito', async () => { const composer = makeComposer(); const _csLoadItems = vi.fn().mockRejectedValue(new Error('boom')); const { _loadCommitmentItemsForEvent, commitmentItems } = setup({ composer, _csLoadItems }); await _loadCommitmentItemsForEvent('evt-1'); expect(commitmentItems.value).toEqual([]); expect(composer.billingType.value).toBe('gratuito'); }); }); describe('ensureServicesLoaded', () => { it('carrega só uma vez (gate)', async () => { const { ensureServicesLoaded, loadServices } = setup(); await ensureServicesLoaded(); await ensureServicesLoaded(); await ensureServicesLoaded(); expect(loadServices).toHaveBeenCalledTimes(1); }); it('skip sem ownerId', async () => { const { ensureServicesLoaded, loadServices } = setup({}, { ownerId: '' }); await ensureServicesLoaded(); expect(loadServices).not.toHaveBeenCalled(); }); it('resetServicesGate permite re-load', async () => { const { ensureServicesLoaded, resetServicesGate, loadServices } = setup(); await ensureServicesLoaded(); resetServicesGate(); await ensureServicesLoaded(); expect(loadServices).toHaveBeenCalledTimes(2); }); });