Files
portal/app/tests/Frontend/AdminSupplierPricesView.spec.ts
T
Дмитрий f94552d452 WIP чекпойнт: lead-region/supplier бэкенд + фронт-редизайн + Pint + тесты
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 05:17:12 +03:00

206 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});