fix(admin): G1 — error/success handling in AdminPricingTiersView submit/delete

axios.post/delete теперь обёрнуты в try/catch с extractErrorMessage()
хелпером из api/client.ts (same pattern as AdminSystemView.vue:32-45).
errorMessage отображается в v-alert (closable, type=error, tonal),
successMessage — в v-snackbar (color=success, 4s timeout).

На failed submit диалог остаётся открытым чтобы пользователь мог
исправить и повторить (UX-pattern). saving=false гарантированно
сбрасывается в finally.

+4 Vitest specs (submit error / submit success / delete error / delete success).
Регрессий 0.

Closes audit ID G1 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 09:12:19 +03:00
parent e8d5025656
commit 72a00641fa
2 changed files with 146 additions and 9 deletions
@@ -2,6 +2,19 @@
<div class="admin-pricing-tiers-view">
<h1 class="text-h4 mb-6">Тарифная сетка</h1>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="compact"
data-testid="pricing-error-alert"
closable
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<v-card class="mb-6" elevation="1">
<v-card-title>
Текущая активная сетка
@@ -88,21 +101,31 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="successToastOpen"
:timeout="4000"
color="success"
location="bottom right"
data-testid="pricing-success-toast"
>
{{ successMessage }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { extractErrorMessage } from '../../api/client';
/**
* SaaS-admin → Тарифная сетка (Plan 4 Task 9).
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
*
* Backend: AdminPricingTiersController (GET/POST/DELETE).
* Палитра Forest + JetBrains Mono для tnum-цифр.
*
* defineExpose ниже — для Vitest unit-тестов (`load`/`submit`/`confirmDelete`/
* `editorOpen`/`active`/`scheduled`/`editor`). На прод-сборку это не влияет.
* defineExpose ниже — для Vitest unit-тестов.
*/
interface Tier {
@@ -122,6 +145,11 @@ const scheduled = ref<Record<string, Tier[]>>({});
const editorOpen = ref(false);
const saving = ref(false);
// Sprint 1 G1: error/success state для UI feedback.
const errorMessage = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const successToastOpen = ref(false);
const defaultEditor: EditorRow[] = [
{ tier_no: 1, leads_in_tier: 100, price_rub: '500.00' },
{ tier_no: 2, leads_in_tier: 200, price_rub: '450.00' },
@@ -149,17 +177,28 @@ const nextMonthStart = computed(() => {
const hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);
async function load(): Promise<void> {
const { data } = await axios.get('/api/admin/pricing-tiers');
active.value = data.data.active;
scheduled.value = data.data.scheduled || {};
try {
const { data } = await axios.get('/api/admin/pricing-tiers');
active.value = data.data.active;
scheduled.value = data.data.scheduled || {};
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифную сетку.');
}
}
async function submit(): Promise<void> {
saving.value = true;
errorMessage.value = null;
successMessage.value = null;
try {
await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
editorOpen.value = false;
successMessage.value = `Сохранено: новая сетка вступит в силу с ${nextMonthStart.value}.`;
successToastOpen.value = true;
await load();
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось сохранить тарифную сетку.');
// Диалог остаётся открытым — пользователь может исправить и повторить.
} finally {
saving.value = false;
}
@@ -169,13 +208,33 @@ async function confirmDelete(effectiveFrom: string): Promise<void> {
if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
return;
}
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
await load();
errorMessage.value = null;
successMessage.value = null;
try {
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
successToastOpen.value = true;
await load();
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
}
}
onMounted(load);
defineExpose({ load, submit, confirmDelete, editorOpen, active, scheduled, editor });
defineExpose({
load,
submit,
confirmDelete,
editorOpen,
active,
scheduled,
editor,
errorMessage,
successMessage,
successToastOpen,
saving,
});
</script>
<style scoped>
@@ -78,3 +78,81 @@ describe('AdminPricingTiersView', () => {
expect(axios.delete).toHaveBeenCalledWith('/api/admin/pricing-tiers/scheduled/2026-06-01');
});
});
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
beforeEach(() => {
// axios.isAxiosError is auto-mocked as vi.fn() by vi.mock('axios').
// We need it to return true so extractErrorMessage() can read response.data.message.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.isAxiosError as any).mockReturnValue(true);
});
it('submit() shows errorMessage when axios.post rejects with 422', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.post as any).mockRejectedValue({
response: { status: 422, data: { message: 'Validation failed: tier 7 leads_in_tier must be null' } },
});
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// Open editor first (submit is called from dialog) so we can verify it stays open on error.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).submit();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.errorMessage).toContain('Validation failed');
expect(vm.saving).toBe(false);
// Dialog should remain OPEN so user can fix and retry
expect(vm.editorOpen).toBe(true);
});
it('submit() shows successMessage on 200', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.post as any).mockResolvedValue({ data: { effective_from: '2026-06-01' } });
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).submit();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.successMessage).toContain('Сохранено');
expect(vm.errorMessage).toBe(null);
expect(vm.saving).toBe(false);
expect(vm.editorOpen).toBe(false);
});
it('confirmDelete() shows errorMessage when axios.delete rejects', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.delete as any).mockRejectedValue({
response: { status: 500, data: { message: 'Database connection failed' } },
});
window.confirm = vi.fn(() => true);
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).confirmDelete('2026-06-01');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).errorMessage).toContain('Database connection failed');
});
it('confirmDelete() shows successMessage on OK', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.delete as any).mockResolvedValue({ data: { ok: true } });
window.confirm = vi.fn(() => true);
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).confirmDelete('2026-06-01');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).successMessage).toContain('Удалено');
});
});