import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import AdminSupplierPricesView from '../../resources/js/views/admin/AdminSupplierPricesView.vue'; /** * Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`), * с нужным `response.data.message`. */ function makeAxiosError(message: string, status = 422): unknown { return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } }, }); } vi.mock('../../resources/js/api/admin', async (importOriginal) => { const orig = await importOriginal(); return { ...orig, getAdminSuppliers: vi.fn(), updateAdminSupplier: vi.fn(), }; }); const adminApi = await import('../../resources/js/api/admin'); // Auto-импорт компонентов/директив Vuetify подхватывает vite-plugin-vuetify // из vitest.config.ts (см. AdminPricingTiersView.spec.ts). const vuetify = createVuetify(); const mockSuppliers = [ { id: 1, code: 'b1', name: 'B1 — Сайты и Звонки', cost_rub: '1.00', quality_score: '1.00', is_active: true }, { id: 2, code: 'b2', name: 'B2 — SMS', cost_rub: '1.50', quality_score: '1.00', is_active: true }, { id: 3, code: 'b3', name: 'B3 — SMS', cost_rub: '1.20', quality_score: '0.95', is_active: true }, ]; describe('AdminSupplierPricesView', () => { beforeEach(() => { vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers); vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(mockSuppliers[0]); }); it('renders 3 supplier rows', async () => { const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); expect(wrapper.text()).toContain('b1'); expect(wrapper.text()).toContain('b2'); expect(wrapper.text()).toContain('b3'); }); it('save() fires PATCH with cost_rub/quality_score/is_active', async () => { const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (wrapper.vm as any).save({ id: 1, code: 'b1', name: '', cost_rub: '2.00', quality_score: '1.00', is_active: true, }); expect(adminApi.updateAdminSupplier).toHaveBeenCalledWith(1, { cost_rub: '2.00', quality_score: '1.00', is_active: true, }); }); it('renders quality_score, cost_rub as editable text-fields', async () => { const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); const inputs = wrapper.findAll('input[type="number"]'); expect(inputs.length).toBeGreaterThanOrEqual(6); }); it('each input/switch has explicit aria-label combining supplier name + field role', async () => { const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); // 3 suppliers × 3 fields = 9 controls const expectedLabels = [ 'Cost (₽) для B1 — Сайты и Звонки', 'Quality для B1 — Сайты и Звонки', 'Active для B1 — Сайты и Звонки', 'Cost (₽) для B2 — SMS', 'Quality для B2 — SMS', 'Active для B2 — SMS', 'Cost (₽) для B3 — SMS', 'Quality для B3 — SMS', 'Active для B3 — SMS', ]; for (const label of expectedLabels) { const node = wrapper.find(`[aria-label="${label}"]`); expect(node.exists(), `aria-label="${label}" not found`).toBe(true); } }); }); describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => { afterEach(() => { vi.clearAllMocks(); }); it('save() shows per-row errorMessage when updateAdminSupplier rejects', async () => { vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([ { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '120.00', quality_score: '8.50', is_active: true }, ]); vi.mocked(adminApi.updateAdminSupplier).mockRejectedValue(makeAxiosError('cost_rub must be non-negative', 422)); const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); const row = { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '-5.00', quality_score: '8.50', is_active: true, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any await (wrapper.vm as any).save(row); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; expect(vm.errorMessages[1]).toContain('cost_rub'); expect(vm.saving[1]).toBe(false); }); it('save() shows successMessage on 200', async () => { vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([ { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '100.00', quality_score: '9.00', is_active: true }, ]); vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue({ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true, }); const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); const row = { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any await (wrapper.vm as any).save(row); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; expect(vm.successToastOpen).toBe(true); expect(vm.successToastText).toContain('Supplier 2'); expect(vm.errorMessages[2]).toBeUndefined(); expect(vm.saving[2]).toBe(false); }); it('load() sets fetchError when getAdminSuppliers rejects', async () => { vi.mocked(adminApi.getAdminSuppliers).mockRejectedValue(makeAxiosError('Database connection lost', 500)); const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; expect(vm.fetchError).toContain('Database connection lost'); }); it('save() clears previous error on successful retry', async () => { vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([ { id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true }, ]); // First call fails vi.mocked(adminApi.updateAdminSupplier).mockRejectedValueOnce(makeAxiosError('transient', 500)); // Second call succeeds vi.mocked(adminApi.updateAdminSupplier).mockResolvedValueOnce({ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true, }); const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); await new Promise((r) => setTimeout(r, 50)); const row = { id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any await (wrapper.vm as any).save(row); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((wrapper.vm as any).errorMessages[3]).toContain('transient'); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (wrapper.vm as any).save(row); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((wrapper.vm as any).errorMessages[3]).toBeUndefined(); }); });