feat(settings): ApiTab wired to api-key + webhook endpoints (closes D2-D5)
Audit D2/D3/D4/D5: all four ApiTab buttons were handler-less and the fields were hardcoded. Adds api/apiKeys.ts + api/webhooks.ts modules and rewires ApiTab: loads the api-key prefix + webhook settings on mount; Copy -> clipboard + snackbar; Regenerate -> confirm dialog -> POST regenerate (full key shown once); Save Webhook -> PUT webhook-settings; Test Webhook -> POST test with the result in a snackbar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { apiClient, ensureCsrfCookie } from './client';
|
||||
|
||||
/**
|
||||
* API-ключи тенанта (audit D2/D3). Backend: ApiKeyController.
|
||||
* Полный ключ доступен только в ответе regenerateApiKey().
|
||||
*/
|
||||
export interface ApiKeyInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface RegeneratedApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
key_prefix: string;
|
||||
}
|
||||
|
||||
export async function listApiKeys(): Promise<ApiKeyInfo[]> {
|
||||
const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(): Promise<RegeneratedApiKey> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<RegeneratedApiKey>('/api/api-keys/regenerate');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiClient, ensureCsrfCookie } from './client';
|
||||
|
||||
/**
|
||||
* Настройки исходящего webhook'а тенанта (audit D4/D5). Backend:
|
||||
* WebhookSettingsController. Полный secret доступен только в ответе
|
||||
* saveWebhookSettings() при первом создании подписки.
|
||||
*/
|
||||
export interface WebhookSettings {
|
||||
target_url: string;
|
||||
secret_prefix: string;
|
||||
events: string[];
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface SavedWebhookSettings extends WebhookSettings {
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface WebhookTestResult {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function getWebhookSettings(): Promise<WebhookSettings | null> {
|
||||
const { data } = await apiClient.get<{ data: WebhookSettings | null }>('/api/tenants/me/webhook-settings');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function saveWebhookSettings(payload: { target_url: string }): Promise<SavedWebhookSettings> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function testWebhook(): Promise<WebhookTestResult> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<WebhookTestResult>('/api/webhooks/test');
|
||||
return data;
|
||||
}
|
||||
@@ -1,18 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Settings → API и Webhook. Token + endpoint URL + signing secret + история webhook-вызовов.
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #api.
|
||||
* Settings → API и Webhook (audit D2/D3/D4/D5 + J5).
|
||||
*
|
||||
* Реальная логика по ТЗ §5/§5.5 + schema v8.7 webhook_dedup_keys (CTO-17 addendum).
|
||||
* Token + secret НЕ показываются в открытом виде после генерации (single-time view).
|
||||
* API-ключ: GET /api/api-keys показывает key_prefix; «Копировать» — clipboard
|
||||
* + toast; «Перегенерировать» — POST /api/api-keys/regenerate, полный ключ
|
||||
* показывается ОДИН раз (затем доступен только префикс).
|
||||
* Webhook: GET/PUT /api/tenants/me/webhook-settings (target_url + secret_prefix),
|
||||
* «Тестовый webhook» — POST /api/webhooks/test (реальный unsigned POST).
|
||||
*
|
||||
* Полный ключ и полный секрет показываются один раз — в БД только bcrypt-хэши.
|
||||
* Подписанная outbound-доставка событий — пост-MVP.
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { listApiKeys, regenerateApiKey } from '../../api/apiKeys';
|
||||
import { getWebhookSettings, saveWebhookSettings, testWebhook } from '../../api/webhooks';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const apiToken = ref('lpkapi_7g8h********************************2klm');
|
||||
const webhookUrl = ref('https://crm.example.ru/api/webhook/leads');
|
||||
const signingSecret = ref('whsec_********************************************');
|
||||
// --- API-ключ ---
|
||||
const apiKeyExists = ref(false);
|
||||
const apiTokenDisplay = ref('');
|
||||
const fullKeyShown = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
const apiKeyError = ref<string | null>(null);
|
||||
const regenDialogOpen = ref(false);
|
||||
const regenerating = ref(false);
|
||||
|
||||
// --- Webhook ---
|
||||
const webhookUrl = ref('');
|
||||
const secretDisplay = ref('');
|
||||
const fullSecretShown = ref(false);
|
||||
const secretVisible = ref(false);
|
||||
const webhookError = ref<string | null>(null);
|
||||
const webhookSuccess = ref<string | null>(null);
|
||||
const savingWebhook = ref(false);
|
||||
const testingWebhook = ref(false);
|
||||
|
||||
// --- общий toast (D2 copy + D5 test) ---
|
||||
const toastOpen = ref(false);
|
||||
const toastText = ref('');
|
||||
const toastColor = ref<'success' | 'error'>('success');
|
||||
|
||||
function showToast(text: string, color: 'success' | 'error' = 'success'): void {
|
||||
toastText.value = text;
|
||||
toastColor.value = color;
|
||||
toastOpen.value = true;
|
||||
}
|
||||
|
||||
async function loadApiKey(): Promise<void> {
|
||||
apiKeyError.value = null;
|
||||
try {
|
||||
const keys = await listApiKeys();
|
||||
const active = keys[0];
|
||||
if (active) {
|
||||
apiKeyExists.value = true;
|
||||
apiTokenDisplay.value = active.key_prefix;
|
||||
fullKeyShown.value = false;
|
||||
} else {
|
||||
apiKeyExists.value = false;
|
||||
apiTokenDisplay.value = '';
|
||||
}
|
||||
} catch (err) {
|
||||
apiKeyError.value = extractErrorMessage(err, 'Не удалось загрузить API-ключ.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhook(): Promise<void> {
|
||||
webhookError.value = null;
|
||||
try {
|
||||
const settings = await getWebhookSettings();
|
||||
if (settings) {
|
||||
webhookUrl.value = settings.target_url;
|
||||
secretDisplay.value = settings.secret_prefix;
|
||||
fullSecretShown.value = false;
|
||||
}
|
||||
} catch (err) {
|
||||
webhookError.value = extractErrorMessage(err, 'Не удалось загрузить настройки webhook.');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadApiKey();
|
||||
void loadWebhook();
|
||||
});
|
||||
|
||||
async function copyToken(): Promise<void> {
|
||||
if (apiTokenDisplay.value === '') return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiTokenDisplay.value);
|
||||
showToast('Скопировано в буфер обмена.', 'success');
|
||||
} catch {
|
||||
showToast('Не удалось скопировать — выделите и скопируйте вручную.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRegenerate(): Promise<void> {
|
||||
regenerating.value = true;
|
||||
apiKeyError.value = null;
|
||||
try {
|
||||
const result = await regenerateApiKey();
|
||||
apiTokenDisplay.value = result.key;
|
||||
fullKeyShown.value = true;
|
||||
apiKeyExists.value = true;
|
||||
tokenVisible.value = true;
|
||||
regenDialogOpen.value = false;
|
||||
} catch (err) {
|
||||
apiKeyError.value = extractErrorMessage(err, 'Не удалось перегенерировать ключ.');
|
||||
regenDialogOpen.value = false;
|
||||
} finally {
|
||||
regenerating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebhook(): Promise<void> {
|
||||
if (savingWebhook.value) return;
|
||||
savingWebhook.value = true;
|
||||
webhookError.value = null;
|
||||
webhookSuccess.value = null;
|
||||
try {
|
||||
const result = await saveWebhookSettings({ target_url: webhookUrl.value });
|
||||
if (result.secret) {
|
||||
secretDisplay.value = result.secret;
|
||||
fullSecretShown.value = true;
|
||||
secretVisible.value = true;
|
||||
} else {
|
||||
secretDisplay.value = result.secret_prefix;
|
||||
}
|
||||
webhookSuccess.value = 'Настройки webhook сохранены.';
|
||||
} catch (err) {
|
||||
webhookError.value = extractErrorMessage(err, 'Не удалось сохранить webhook.');
|
||||
} finally {
|
||||
savingWebhook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWebhookTest(): Promise<void> {
|
||||
if (testingWebhook.value) return;
|
||||
testingWebhook.value = true;
|
||||
try {
|
||||
const result = await testWebhook();
|
||||
showToast(result.message, result.ok ? 'success' : 'error');
|
||||
} catch (err) {
|
||||
showToast(extractErrorMessage(err, 'Не удалось отправить тестовый запрос.'), 'error');
|
||||
} finally {
|
||||
testingWebhook.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,18 +157,60 @@ const secretVisible = ref(false);
|
||||
Используется для подписи запросов в публичный API CRM. После регенерации старый ключ перестаёт работать
|
||||
немедленно.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="apiKeyError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="api-key-error"
|
||||
@click:close="apiKeyError = null"
|
||||
>
|
||||
{{ apiKeyError }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="fullKeyShown"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="api-key-once-notice"
|
||||
>
|
||||
Новый ключ показывается <strong>один раз</strong>. Скопируйте и сохраните его сейчас.
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
:model-value="apiToken"
|
||||
:model-value="apiTokenDisplay"
|
||||
:type="tokenVisible ? 'text' : 'password'"
|
||||
:placeholder="apiKeyExists ? '' : 'Ключ ещё не создан — нажмите «Перегенерировать»'"
|
||||
readonly
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="api-key-field"
|
||||
:append-inner-icon="tokenVisible ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="tokenVisible = !tokenVisible"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2">
|
||||
<v-btn variant="outlined" size="small" prepend-icon="mdi-content-copy">Копировать</v-btn>
|
||||
<v-btn variant="outlined" size="small" color="warning" prepend-icon="mdi-refresh">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-copy"
|
||||
:disabled="apiTokenDisplay === ''"
|
||||
data-testid="api-key-copy-btn"
|
||||
@click="copyToken"
|
||||
>
|
||||
Копировать
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="warning"
|
||||
prepend-icon="mdi-refresh"
|
||||
data-testid="api-key-regen-btn"
|
||||
@click="regenDialogOpen = true"
|
||||
>
|
||||
Перегенерировать
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -48,22 +222,117 @@ const secretVisible = ref(false);
|
||||
URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
|
||||
<code>(tenant_id, source_crm_id)</code> в окне 24 ч (антифрод по phone — §10.8.1).
|
||||
</p>
|
||||
<v-text-field v-model="webhookUrl" label="Endpoint URL" variant="outlined" density="comfortable" />
|
||||
|
||||
<v-alert
|
||||
v-if="webhookError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="webhook-error"
|
||||
@click:close="webhookError = null"
|
||||
>
|
||||
{{ webhookError }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="webhookSuccess"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
closable
|
||||
data-testid="webhook-success"
|
||||
@click:close="webhookSuccess = null"
|
||||
>
|
||||
{{ webhookSuccess }}
|
||||
</v-alert>
|
||||
<v-alert
|
||||
v-if="fullSecretShown"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="webhook-secret-once-notice"
|
||||
>
|
||||
Signing secret показывается <strong>один раз</strong>. Сохраните его в настройках вашего приёмника.
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
:model-value="signingSecret"
|
||||
v-model="webhookUrl"
|
||||
label="Endpoint URL"
|
||||
placeholder="https://..."
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="webhook-url-field"
|
||||
/>
|
||||
<v-text-field
|
||||
:model-value="secretDisplay"
|
||||
:type="secretVisible ? 'text' : 'password'"
|
||||
label="Signing secret (HMAC)"
|
||||
:placeholder="secretDisplay ? '' : 'Появится после первого сохранения URL'"
|
||||
readonly
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
data-testid="webhook-secret-field"
|
||||
:append-inner-icon="secretVisible ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="secretVisible = !secretVisible"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2">
|
||||
<v-btn color="primary" variant="flat" size="small">Сохранить</v-btn>
|
||||
<v-btn variant="outlined" size="small" prepend-icon="mdi-test-tube"> Тестовый webhook </v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:loading="savingWebhook"
|
||||
data-testid="webhook-save-btn"
|
||||
@click="saveWebhook"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
prepend-icon="mdi-test-tube"
|
||||
:loading="testingWebhook"
|
||||
data-testid="webhook-test-btn"
|
||||
@click="runWebhookTest"
|
||||
>
|
||||
Тестовый webhook
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="regenDialogOpen" :max-width="440" data-testid="regen-dialog">
|
||||
<v-card>
|
||||
<v-card-title>Перегенерация API-ключа</v-card-title>
|
||||
<v-card-text>
|
||||
Текущий ключ перестанет работать немедленно. Все интеграции с ним нужно будет обновить. Продолжить?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="regenerating" @click="regenDialogOpen = false">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="flat"
|
||||
:loading="regenerating"
|
||||
data-testid="regen-confirm-btn"
|
||||
@click="confirmRegenerate"
|
||||
>
|
||||
Перегенерировать
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="toastOpen"
|
||||
:timeout="4000"
|
||||
:color="toastColor"
|
||||
location="bottom right"
|
||||
data-testid="api-tab-toast"
|
||||
>
|
||||
{{ toastText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
vi.mock('../../resources/js/api/apiKeys', () => ({
|
||||
listApiKeys: vi.fn(),
|
||||
regenerateApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/webhooks', () => ({
|
||||
getWebhookSettings: vi.fn(),
|
||||
saveWebhookSettings: vi.fn(),
|
||||
testWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/client', () => ({
|
||||
apiClient: {},
|
||||
ensureCsrfCookie: vi.fn(),
|
||||
extractValidationErrors: vi.fn(() => null),
|
||||
extractErrorMessage: vi.fn((_e, fallback) => fallback ?? 'Произошла ошибка.'),
|
||||
extractRateLimitRetry: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import * as apiKeysApi from '../../resources/js/api/apiKeys';
|
||||
import * as webhooksApi from '../../resources/js/api/webhooks';
|
||||
import ApiTab from '../../resources/js/views/settings/ApiTab.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const mountTab = () => mount(ApiTab, { global: { plugins: [vuetify] } });
|
||||
|
||||
describe('ApiTab.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(webhooksApi.getWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
|
||||
});
|
||||
|
||||
it('загружает и показывает префикс API-ключа', async () => {
|
||||
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
|
||||
]);
|
||||
const wrapper = mountTab();
|
||||
await flush();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.apiKeyExists).toBe(true);
|
||||
expect(vm.apiTokenDisplay).toBe('lpkapi_abc');
|
||||
});
|
||||
|
||||
it('copyToken() пишет в буфер и открывает toast', async () => {
|
||||
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
|
||||
]);
|
||||
const wrapper = mountTab();
|
||||
await flush();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
await vm.copyToken();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('lpkapi_abc');
|
||||
expect(vm.toastOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('confirmRegenerate() показывает полный новый ключ один раз', async () => {
|
||||
(apiKeysApi.regenerateApiKey as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'API-ключ',
|
||||
key: 'lpkapi_FULLNEWKEYVALUE0000000000',
|
||||
key_prefix: 'lpkapi_FUL',
|
||||
});
|
||||
const wrapper = mountTab();
|
||||
await flush();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
await vm.confirmRegenerate();
|
||||
expect(apiKeysApi.regenerateApiKey).toHaveBeenCalled();
|
||||
expect(vm.apiTokenDisplay).toBe('lpkapi_FULLNEWKEYVALUE0000000000');
|
||||
expect(vm.fullKeyShown).toBe(true);
|
||||
});
|
||||
|
||||
it('загружает настройки webhook', async () => {
|
||||
(webhooksApi.getWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
target_url: 'https://crm.example.ru/hook',
|
||||
secret_prefix: 'whsec_abc',
|
||||
events: ['deal.created'],
|
||||
is_active: true,
|
||||
});
|
||||
const wrapper = mountTab();
|
||||
await flush();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.webhookUrl).toBe('https://crm.example.ru/hook');
|
||||
expect(vm.secretDisplay).toBe('whsec_abc');
|
||||
});
|
||||
|
||||
it('saveWebhook() вызывает saveWebhookSettings и показывает secret один раз', async () => {
|
||||
(webhooksApi.saveWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
target_url: 'https://crm.example.ru/hook',
|
||||
secret_prefix: 'whsec_new',
|
||||
events: ['deal.created'],
|
||||
is_active: true,
|
||||
secret: 'whsec_FULLSECRETVALUE0000',
|
||||
});
|
||||
const wrapper = mountTab();
|
||||
await flush();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.webhookUrl = 'https://crm.example.ru/hook';
|
||||
await vm.saveWebhook();
|
||||
expect(webhooksApi.saveWebhookSettings).toHaveBeenCalledWith({ target_url: 'https://crm.example.ru/hook' });
|
||||
expect(vm.secretDisplay).toBe('whsec_FULLSECRETVALUE0000');
|
||||
expect(vm.fullSecretShown).toBe(true);
|
||||
expect(vm.webhookSuccess).toBeTruthy();
|
||||
});
|
||||
|
||||
it('runWebhookTest() вызывает testWebhook и открывает toast с результатом', async () => {
|
||||
(webhooksApi.testWebhook as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
message: 'Тестовый запрос доставлен (HTTP 200).',
|
||||
});
|
||||
const wrapper = mountTab();
|
||||
await flush();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
await vm.runWebhookTest();
|
||||
expect(webhooksApi.testWebhook).toHaveBeenCalled();
|
||||
expect(vm.toastOpen).toBe(true);
|
||||
expect(vm.toastText).toContain('HTTP 200');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user