3917fb1344
Задача 8e. SaaS-оператор видит кампании в статусе queued по всем тенантам и вписывает номер оформленного в конструкторе Яндекса адаптивного креатива yandex_creative_id, после чего кампанию можно запускать. Два эндпоинта AdminAdvertisingController campaignsAwaiting и setCampaignCreative под saas-admin+admin-db, карточка в AdminAdvertisingView, api-функции и типы. 🔴 Запись строго через сырой DB::table update только колонки yandex_creative_id Eloquent-билдер добавил бы updated_at, а колоночный GRANT у crm_admin_user разрешает писать лишь эту колонку тесты идут под суперюзером и это не ловят. RLS не трогаем ad_campaigns уже покрыт srv_bypass и грантами. Бэкенд AdminAd 19/19, реклама-модуль 195/195, фронт админки 11/11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import AdminAdvertisingView from '../../resources/js/views/admin/AdminAdvertisingView.vue';
|
||
import type { AdminAdvertisingSpendResponse, AdminAwaitingCampaign } from '../../resources/js/api/admin';
|
||
|
||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||
return {
|
||
...orig,
|
||
fetchAdvertisingSpend: vi.fn(),
|
||
fetchAdSettings: vi.fn(),
|
||
updateAdSettings: vi.fn(),
|
||
fetchAdAwaitingCampaigns: vi.fn(),
|
||
setAdCampaignCreative: vi.fn(),
|
||
};
|
||
});
|
||
|
||
const adminApi = await import('../../resources/js/api/admin');
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
vi.mocked(adminApi.fetchAdSettings).mockResolvedValue({ client_cpm_rub: '150.00', ad_margin_percent: '40.00' });
|
||
vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValue([]);
|
||
});
|
||
|
||
function makeAwaitingCampaign(overrides: Partial<AdminAwaitingCampaign> = {}): AdminAwaitingCampaign {
|
||
return {
|
||
id: 1,
|
||
tenant_id: 1,
|
||
tenant_name: 'Окна Москва',
|
||
name: 'Кампания на запуск',
|
||
status: 'queued',
|
||
yandex_creative_id: null,
|
||
landing_url: 'https://example.ru/landing',
|
||
estimated_impressions: 12000,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function makeResponse(overrides: Partial<AdminAdvertisingSpendResponse> = {}): AdminAdvertisingSpendResponse {
|
||
return {
|
||
data: [
|
||
{
|
||
tenant_id: 1,
|
||
tenant_name: 'Окна Москва',
|
||
client_spend_rub: '50000.00',
|
||
yandex_cost_rub: '40000.00',
|
||
our_margin_rub: '10000.00',
|
||
},
|
||
{
|
||
tenant_id: 2,
|
||
tenant_name: 'Двери СПб',
|
||
client_spend_rub: '20000.00',
|
||
yandex_cost_rub: '16000.00',
|
||
our_margin_rub: '4000.00',
|
||
},
|
||
],
|
||
totals: {
|
||
client_spend_rub: '70000.00',
|
||
yandex_cost_rub: '56000.00',
|
||
our_margin_rub: '14000.00',
|
||
},
|
||
ad_margin_percent: '40.00',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const mountView = () =>
|
||
mount(AdminAdvertisingView, {
|
||
global: { plugins: [createVuetify()] },
|
||
});
|
||
|
||
describe('AdminAdvertisingView ↔ GET /api/admin/advertising/spend', () => {
|
||
it('на mount зовёт fetchAdvertisingSpend(current_month) и рендерит строки + итоговую маржу', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(1);
|
||
expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledWith('current_month');
|
||
|
||
expect(wrapper.findAll('[data-testid="ad-spend-row"]')).toHaveLength(2);
|
||
|
||
const marginEl = wrapper.find('[data-testid="ad-spend-total-margin"]');
|
||
expect(marginEl.exists()).toBe(true);
|
||
expect(marginEl.text()).toMatch(/14.000/);
|
||
});
|
||
|
||
it('смена периода вызывает перезагрузку с новым period', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValue(makeResponse());
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(1);
|
||
|
||
const vm = wrapper.vm as unknown as { period: string };
|
||
vm.period = 'last_month';
|
||
await flushPromises();
|
||
|
||
expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(2);
|
||
expect(adminApi.fetchAdvertisingSpend).toHaveBeenLastCalledWith('last_month');
|
||
});
|
||
|
||
it('пустые данные → сообщение "За период рекламных расходов нет"', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(
|
||
makeResponse({
|
||
data: [],
|
||
totals: { client_spend_rub: '0.00', yandex_cost_rub: '0.00', our_margin_rub: '0.00' },
|
||
}),
|
||
);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.findAll('[data-testid="ad-spend-row"]')).toHaveLength(0);
|
||
expect(wrapper.text()).toContain('За период рекламных расходов нет');
|
||
});
|
||
|
||
it('reject → alert с ошибкой виден', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockRejectedValueOnce(new Error('500'));
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('AdminAdvertisingView ↔ GET/PUT /api/admin/advertising/settings (client_cpm_rub + ad_margin_percent)', () => {
|
||
it('на mount зовёт fetchAdSettings и показывает загруженные значения в полях', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
vi.mocked(adminApi.fetchAdSettings).mockResolvedValueOnce({
|
||
client_cpm_rub: '220.50',
|
||
ad_margin_percent: '35.00',
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(adminApi.fetchAdSettings).toHaveBeenCalledTimes(1);
|
||
const input = wrapper.find('[data-testid="ad-cpm-input"] input');
|
||
expect((input.element as HTMLInputElement).value).toBe('220.50');
|
||
const marginInput = wrapper.find('[data-testid="ad-margin-input"] input');
|
||
expect((marginInput.element as HTMLInputElement).value).toBe('35.00');
|
||
});
|
||
|
||
it('клик «Сохранить» зовёт updateAdSettings с обоими полями и показывает "Цена сохранена"', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
vi.mocked(adminApi.fetchAdSettings).mockResolvedValueOnce({
|
||
client_cpm_rub: '150.00',
|
||
ad_margin_percent: '40.00',
|
||
});
|
||
vi.mocked(adminApi.updateAdSettings).mockResolvedValueOnce({
|
||
client_cpm_rub: '300.00',
|
||
ad_margin_percent: '45.00',
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
const input = wrapper.find('[data-testid="ad-cpm-input"] input');
|
||
await input.setValue('300.00');
|
||
const marginInput = wrapper.find('[data-testid="ad-margin-input"] input');
|
||
await marginInput.setValue('45.00');
|
||
|
||
await wrapper.find('[data-testid="ad-cpm-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.updateAdSettings).toHaveBeenCalledTimes(1);
|
||
expect(adminApi.updateAdSettings).toHaveBeenCalledWith({
|
||
client_cpm_rub: '300.00',
|
||
ad_margin_percent: '45.00',
|
||
});
|
||
|
||
const saved = wrapper.find('[data-testid="ad-cpm-saved"]');
|
||
expect(saved.exists()).toBe(true);
|
||
expect(saved.text()).toContain('Цена сохранена');
|
||
});
|
||
|
||
it('updateAdSettings reject (422) → показывает ошибку, не "Цена сохранена"', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
vi.mocked(adminApi.fetchAdSettings).mockResolvedValueOnce({
|
||
client_cpm_rub: '150.00',
|
||
ad_margin_percent: '40.00',
|
||
});
|
||
vi.mocked(adminApi.updateAdSettings).mockRejectedValueOnce(new Error('422'));
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
await wrapper.find('[data-testid="ad-cpm-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-testid="ad-cpm-error"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="ad-cpm-saved"]').exists()).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('AdminAdvertisingView ↔ кампании, ждущие номер креатива (8e)', () => {
|
||
it('на mount зовёт fetchAdAwaitingCampaigns и рендерит строки', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([
|
||
makeAwaitingCampaign({ id: 1, name: 'Кампания 1' }),
|
||
makeAwaitingCampaign({ id: 2, name: 'Кампания 2' }),
|
||
]);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(adminApi.fetchAdAwaitingCampaigns).toHaveBeenCalledTimes(1);
|
||
expect(wrapper.findAll('[data-testid="ad-awaiting-row"]')).toHaveLength(2);
|
||
expect(wrapper.text()).toContain('Кампания 1');
|
||
expect(wrapper.text()).toContain('Кампания 2');
|
||
});
|
||
|
||
it('пустой список → сообщение "Кампаний, ждущих номер креатива, нет"', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([]);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.findAll('[data-testid="ad-awaiting-row"]')).toHaveLength(0);
|
||
expect(wrapper.find('[data-testid="ad-awaiting-empty"]').exists()).toBe(true);
|
||
expect(wrapper.text()).toContain('Кампаний, ждущих номер креатива, нет');
|
||
});
|
||
|
||
it('клик «Сохранить» зовёт setAdCampaignCreative с id кампании и введённым номером', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([
|
||
makeAwaitingCampaign({ id: 42, yandex_creative_id: null }),
|
||
]);
|
||
vi.mocked(adminApi.setAdCampaignCreative).mockResolvedValueOnce({ id: 42, yandex_creative_id: 111222333 });
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
const input = wrapper.find('[data-testid="ad-creative-input"] input');
|
||
await input.setValue('111222333');
|
||
|
||
await wrapper.find('[data-testid="ad-creative-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.setAdCampaignCreative).toHaveBeenCalledTimes(1);
|
||
expect(adminApi.setAdCampaignCreative).toHaveBeenCalledWith(42, 111222333);
|
||
|
||
const saved = wrapper.find('[data-testid="ad-creative-saved"]');
|
||
expect(saved.exists()).toBe(true);
|
||
expect(saved.text()).toContain('Сохранено');
|
||
});
|
||
|
||
it('setAdCampaignCreative reject → показывает ошибку по строке, не "Сохранено"', async () => {
|
||
vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse());
|
||
vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([makeAwaitingCampaign({ id: 7 })]);
|
||
vi.mocked(adminApi.setAdCampaignCreative).mockRejectedValueOnce(new Error('422'));
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
const input = wrapper.find('[data-testid="ad-creative-input"] input');
|
||
await input.setValue('5');
|
||
|
||
await wrapper.find('[data-testid="ad-creative-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-testid="ad-creative-error"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="ad-creative-saved"]').exists()).toBe(false);
|
||
});
|
||
});
|