5a4c0e0235
Часть 5c — баннеры: клиент грузит свой готовый файл на каждый из 15 размеров вместо автогенерации из одной картинки. Частичное утверждение флагом included, замена и удаление отдельного баннера, валидация точного размера и веса. Админ-поле цены за 1000 показов. Пример CSV для скачивания и подъём лимита загрузки. Часть 5d — два режима сбора аудитории. Авто: скользящее окно, обновляется ежедневно, только контакты системы. Ручной: снимок сделок за период плюс свой список номеров и срок показа. Клиент сам задаёт цену за 1000 показов с дефолтом из админки. Наценка настраивается в админке, по умолчанию 40 процентов, в Директ уходит меньше, клиенту не видна нигде. Миграции: ad_campaign_banners += included; ad_campaigns += mode/snapshot_from/snapshot_to/run_days/client_cpm_rub; ad_settings += ad_margin_percent. RLS-ревью PASS на всех миграциях. Backend 166 тестов, фронт 123 теста, сборка чистая. Маржа и yandex_cost_rub клиенту не сериализуются. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
||
vi.mock('../../resources/js/api/client', () => ({
|
||
apiClient: {
|
||
get: vi.fn(),
|
||
post: vi.fn(),
|
||
patch: vi.fn(),
|
||
delete: vi.fn(),
|
||
},
|
||
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
import {
|
||
fetchWallet,
|
||
fetchCampaigns,
|
||
fetchCampaign,
|
||
createCampaign,
|
||
patchCampaign,
|
||
fetchAudienceSize,
|
||
launchCampaign,
|
||
pauseCampaign,
|
||
resumeCampaign,
|
||
addCreative,
|
||
uploadCreativeImage,
|
||
deleteCampaign,
|
||
uploadCampaignPhones,
|
||
createAdvertisingInvoice,
|
||
topupAdvertisingByCard,
|
||
} from '../../resources/js/api/advertising';
|
||
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
|
||
|
||
describe('api/advertising', () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it('fetchWallet() GETs /api/advertising/wallet', async () => {
|
||
vi.mocked(apiClient.get).mockResolvedValue({
|
||
data: { solvent: true, balance_rub: '100.00', frozen_rub: '20.00', free_rub: '80.00' },
|
||
});
|
||
const w = await fetchWallet();
|
||
expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/wallet');
|
||
expect(w.solvent).toBe(true);
|
||
expect(w.free_rub).toBe('80.00');
|
||
});
|
||
|
||
it('fetchCampaigns() hits GET /api/advertising/campaigns and unwraps data.data', async () => {
|
||
vi.mocked(apiClient.get).mockResolvedValue({
|
||
data: {
|
||
data: [
|
||
{
|
||
id: 1,
|
||
name: 'C',
|
||
status: 'draft',
|
||
weekly_budget_rub: '500.00',
|
||
audience_days: 10,
|
||
launched_at: null,
|
||
},
|
||
],
|
||
},
|
||
});
|
||
const rows = await fetchCampaigns();
|
||
expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns');
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0].name).toBe('C');
|
||
});
|
||
|
||
it('fetchCampaigns() returns [] when data.data missing', async () => {
|
||
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
|
||
const rows = await fetchCampaigns();
|
||
expect(rows).toEqual([]);
|
||
});
|
||
|
||
it('fetchCampaign(id) GETs /api/advertising/campaigns/{id} and returns campaign+ads+spent_rub', async () => {
|
||
vi.mocked(apiClient.get).mockResolvedValue({
|
||
data: {
|
||
campaign: {
|
||
id: 3,
|
||
name: 'X',
|
||
status: 'running',
|
||
weekly_budget_rub: '1000.00',
|
||
audience_days: 20,
|
||
launched_at: '2026-07-01',
|
||
},
|
||
ads: [],
|
||
spent_rub: '10.00',
|
||
},
|
||
});
|
||
const detail = await fetchCampaign(3);
|
||
expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3');
|
||
expect(detail.campaign.id).toBe(3);
|
||
expect(detail.spent_rub).toBe('10.00');
|
||
});
|
||
|
||
it('createCampaign() ensures csrf then POSTs /api/advertising/campaigns with payload', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({
|
||
data: {
|
||
id: 5,
|
||
name: 'X',
|
||
status: 'draft',
|
||
weekly_budget_rub: '2500.00',
|
||
audience_days: 10,
|
||
launched_at: null,
|
||
},
|
||
});
|
||
const payload = { name: 'X', audience_days: 10, use_uploaded_list: false, weekly_budget_rub: '2500.00' };
|
||
const c = await createCampaign(payload);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns', payload);
|
||
expect(c.id).toBe(5);
|
||
});
|
||
|
||
it('patchCampaign() ensures csrf then PATCHes /api/advertising/campaigns/{id}', async () => {
|
||
vi.mocked(apiClient.patch).mockResolvedValue({
|
||
data: {
|
||
id: 5,
|
||
name: 'X2',
|
||
status: 'draft',
|
||
weekly_budget_rub: '3000.00',
|
||
audience_days: 15,
|
||
launched_at: null,
|
||
},
|
||
});
|
||
const c = await patchCampaign(5, { weekly_budget_rub: '3000.00' });
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.patch).toHaveBeenCalledWith('/api/advertising/campaigns/5', { weekly_budget_rub: '3000.00' });
|
||
expect(c.name).toBe('X2');
|
||
});
|
||
|
||
it('fetchAudienceSize() passes days as query param when given', async () => {
|
||
vi.mocked(apiClient.get).mockResolvedValue({ data: { size: 120, min: 100, enough: true, hint: null } });
|
||
const s = await fetchAudienceSize(3, { days: 14 });
|
||
expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3/audience-size', {
|
||
params: { days: 14 },
|
||
});
|
||
expect(s.enough).toBe(true);
|
||
});
|
||
|
||
it('fetchAudienceSize() omits days param when not given', async () => {
|
||
vi.mocked(apiClient.get).mockResolvedValue({ data: { size: 5, min: 100, enough: false, hint: 'мало' } });
|
||
const s = await fetchAudienceSize(3);
|
||
expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3/audience-size', { params: {} });
|
||
expect(s.enough).toBe(false);
|
||
});
|
||
|
||
it('launchCampaign() ensures csrf then POSTs launch endpoint', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'pending_moderation' } });
|
||
const res = await launchCampaign(7);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/launch');
|
||
expect(res.status).toBe('pending_moderation');
|
||
});
|
||
|
||
it('pauseCampaign() ensures csrf then POSTs pause endpoint', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'paused' } });
|
||
const res = await pauseCampaign(7);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/pause');
|
||
expect(res.status).toBe('paused');
|
||
});
|
||
|
||
it('resumeCampaign() ensures csrf then POSTs resume endpoint', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'running' } });
|
||
const res = await resumeCampaign(7);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/resume');
|
||
expect(res.status).toBe('running');
|
||
});
|
||
|
||
it('addCreative() ensures csrf then POSTs ads endpoint with payload', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({
|
||
data: {
|
||
id: 9,
|
||
campaign_id: 7,
|
||
title: 'T',
|
||
text: 'Txt',
|
||
href: 'https://x.ru',
|
||
title2: null,
|
||
moderation_status: 'draft',
|
||
},
|
||
});
|
||
const payload = { title: 'T', text: 'Txt', href: 'https://x.ru' };
|
||
const ad = await addCreative(7, payload);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/ads', payload);
|
||
expect(ad.id).toBe(9);
|
||
});
|
||
|
||
it('uploadCreativeImage() ensures csrf then POSTs multipart FormData to image endpoint', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({ data: { hash: 'abc123' } });
|
||
const file = new File(['x'], 'pic.png', { type: 'image/png' });
|
||
const res = await uploadCreativeImage(7, 9, file);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/ads/9/image', expect.any(FormData));
|
||
const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
|
||
expect(sentForm.get('file')).toBe(file);
|
||
expect(res.hash).toBe('abc123');
|
||
});
|
||
|
||
it('deleteCampaign() ensures csrf then DELETEs /api/advertising/campaigns/{id}', async () => {
|
||
vi.mocked(apiClient.delete).mockResolvedValue({ data: null });
|
||
await deleteCampaign(12);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.delete).toHaveBeenCalledWith('/api/advertising/campaigns/12');
|
||
});
|
||
|
||
it('uploadCampaignPhones() ensures csrf then POSTs FormData to phones endpoint and returns {recognized,skipped}', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({ data: { recognized: 10, skipped: 2 } });
|
||
const file = new File(['79261234567'], 'phones.txt', { type: 'text/plain' });
|
||
const res = await uploadCampaignPhones(7, { file, text: '79261111111' });
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/phones', expect.any(FormData));
|
||
const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
|
||
expect(sentForm.get('file')).toBe(file);
|
||
expect(sentForm.get('text')).toBe('79261111111');
|
||
expect(res).toEqual({ recognized: 10, skipped: 2 });
|
||
});
|
||
|
||
it('uploadCampaignPhones() omits file/text from FormData when not given', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({ data: { recognized: 3, skipped: 0 } });
|
||
await uploadCampaignPhones(7, {});
|
||
const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
|
||
expect(sentForm.get('file')).toBeNull();
|
||
expect(sentForm.get('text')).toBeNull();
|
||
});
|
||
|
||
it('createAdvertisingInvoice() ensures csrf then POSTs /api/billing/invoices с credit_target=advertising', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({
|
||
data: {
|
||
invoice: {
|
||
id: 1,
|
||
invoice_number: 'СЧ-2026-0001',
|
||
amount_total: '5000.00',
|
||
pdf_url: '/api/billing/invoices/1/pdf',
|
||
},
|
||
},
|
||
});
|
||
const invoice = await createAdvertisingInvoice(5000);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/billing/invoices', {
|
||
amount_rub: 5000,
|
||
credit_target: 'advertising',
|
||
});
|
||
expect(invoice.invoice_number).toBe('СЧ-2026-0001');
|
||
expect(invoice.pdf_url).toBe('/api/billing/invoices/1/pdf');
|
||
});
|
||
|
||
it('topupAdvertisingByCard() ensures csrf then POSTs /api/billing/topup с credit_target=advertising (шлюз ВКЛ → confirmation_url)', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({
|
||
data: { confirmation_url: 'https://yoomoney.ru/checkout/pay_x' },
|
||
});
|
||
const res = await topupAdvertisingByCard(5000);
|
||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||
expect(apiClient.post).toHaveBeenCalledWith('/api/billing/topup', {
|
||
amount_rub: 5000,
|
||
credit_target: 'advertising',
|
||
});
|
||
expect(res.confirmation_url).toBe('https://yoomoney.ru/checkout/pay_x');
|
||
});
|
||
|
||
it('topupAdvertisingByCard() returns {ok:true} when the gateway is off (stub instant credit)', async () => {
|
||
vi.mocked(apiClient.post).mockResolvedValue({ data: { ok: true } });
|
||
const res = await topupAdvertisingByCard(3000);
|
||
expect(res.ok).toBe(true);
|
||
expect(res.confirmation_url).toBeUndefined();
|
||
});
|
||
});
|