181 lines
8.3 KiB
TypeScript
181 lines
8.3 KiB
TypeScript
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<typeof import('../../resources/js/api/admin')>();
|
||
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();
|
||
});
|
||
});
|