fix(sales): «Скачать файл для МТС» слал 401 — кнопка была обычной href

Маршрут /api/sales/ad-audience/mts-file висит за middleware auth:sales
(Bearer-токен). Обычная <a href> браузерная навигация не отправляет
заголовок Authorization — начальник получал бы 401 вместо файла.

Качаем через тот же axios-слой, что и остальные запросы портала продаж
(downloadSalesAdAudienceMtsFile в api/sales.ts, токен уходит в заголовке),
дальше отдаём файл через Blob + временную ссылку. Кнопка переведена
с href на @click.

Тест на кнопку переписан: раньше проверял href на маршрут (то есть
проверял ровно то, что было сломано), теперь проверяет, что href нет
и что клик зовёт функцию скачивания через API.
This commit is contained in:
Дмитрий
2026-07-20 08:37:43 +03:00
parent 54ffba360d
commit 4529cbbd06
3 changed files with 64 additions and 5 deletions
+32
View File
@@ -1083,6 +1083,38 @@ export async function setSalesAdAudienceChannels(
return data;
}
/**
* GET /api/sales/ad-audience/mts-file (Bearer, начальник) — файл со списком
* номеров для МТС.
*
* Маршрут за auth:sales (Bearer-токен, который SPA шлёт через axios) — обычная
* <a href> его не пошлёт и получит 401. Качаем через тот же axios-слой, что
* и остальные запросы, и отдаём файл пользователю через Blob + временную ссылку.
*/
export async function downloadSalesAdAudienceMtsFile(): Promise<void> {
const response = await axios.get('/api/sales/ad-audience/mts-file', {
responseType: 'blob',
headers: {
Accept: 'text/plain',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
const disposition = response.headers['content-disposition'] as string | undefined;
const match = disposition?.match(/filename="?([^"]+)"?/);
const filename = match?.[1] ?? `liderra-mts-${new Date().toISOString().slice(0, 10)}.txt`;
const blobUrl = URL.createObjectURL(response.data as Blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
}
// ─── Потенциальные клиенты (воронка-канбан) ───────────────────────────────────
/**
@@ -25,6 +25,7 @@ import {
assignSalesAdAudienceFirm,
listSalesManagers,
setSalesAdAudienceChannels,
downloadSalesAdAudienceMtsFile,
extractSalesErrorMessage,
type SalesAdAudienceState,
type SalesAdAudienceDurations,
@@ -301,6 +302,20 @@ async function applyChannels(): Promise<void> {
}
}
/** Начальник жмёт «Скачать файл для МТС» — файл качается через axios (Bearer-токен). */
const downloadingMtsFile = ref(false);
async function downloadMtsFile(): Promise<void> {
downloadingMtsFile.value = true;
try {
await downloadSalesAdAudienceMtsFile();
} catch (e) {
notify(extractSalesErrorMessage(e, 'Не удалось скачать файл для МТС.'), 'error');
} finally {
downloadingMtsFile.value = false;
}
}
// ─── назначение менеджера ────────────────────────────────────────────────────────
const managers = ref<SalesManagerRow[]>([]);
@@ -368,6 +383,7 @@ defineExpose({
selectedFirmIds,
applyingChannels,
pick,
downloadingMtsFile,
managers,
assignOpen,
assignFirm,
@@ -384,6 +400,7 @@ defineExpose({
doAssign,
channelsLabel,
applyChannels,
downloadMtsFile,
});
</script>
@@ -550,8 +567,9 @@ defineExpose({
size="small"
variant="tonal"
class="text-none"
href="/api/sales/ad-audience/mts-file"
:loading="downloadingMtsFile"
data-testid="mts-file"
@click="downloadMtsFile"
>
Скачать файл для МТС
</v-btn>
+13 -4
View File
@@ -59,6 +59,7 @@ vi.mock('../../resources/js/api/sales', async (importOriginal) => {
assignSalesAdAudienceFirm: vi.fn(),
listSalesManagers: vi.fn(),
setSalesAdAudienceChannels: vi.fn(),
downloadSalesAdAudienceMtsFile: vi.fn(),
};
});
@@ -289,13 +290,21 @@ describe('Реклама на кандидатов — экран начальн
expect(wrapper.find('[data-testid="firm-channels"]').text()).toBe('нигде');
});
it('кнопка «Скачать файл для МТС» ведёт на маршрут выгрузки', async () => {
it('кнопка «Скачать файл для МТС» качает файл через axios-слой с токеном, не через href', async () => {
vi.mocked(salesApi.downloadSalesAdAudienceMtsFile).mockResolvedValue(undefined);
const wrapper = factory();
await flushPromises();
const link = wrapper.find('[data-testid="mts-file"]');
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe('/api/sales/ad-audience/mts-file');
const btn = wrapper.find('[data-testid="mts-file"]');
expect(btn.exists()).toBe(true);
// Маршрут за auth:sales (Bearer-токен) — обычный <a href> его не пошлёт и получит 401.
expect(btn.attributes('href')).toBeUndefined();
await btn.trigger('click');
await flushPromises();
expect(salesApi.downloadSalesAdAudienceMtsFile).toHaveBeenCalledTimes(1);
});
// Task 9 плана «Выбор площадки прогрева» (2026-07-19): состояние канала ВК