fix,тесты интерфейса: запрет на выход в сеть — течь была не в одном тесте, а в 93

Промт называл один тест, который печатал сетевую ошибку. Сторож нашёл 93 теста
в 14 файлах: остальные 92 глушились чужими catch внутри рабочего кода и были
невидимы вообще. Запрета на выход наружу в тестах интерфейса не было ни одного
за всю историю — тот же класс, что закрыли 01.08 на PHP.

Сторож в app/tests/Frontend/setup.ts делает две вещи, и обе нужны: обрывает
запрос и запоминает адрес, роняя тест в afterEach. Без второй половины код,
который глушит отказ своим catch, снова всё прячет.

Там же поимённо заглушены четыре фоновые точки оболочки — счётчик сделок,
состояние автоподбора, статус баланса, отметка о посещении. Их не спрашивает
ни один тест разметки, а отказ у всех четырёх и так проглатывается. Остальные
семь файлов глушат свои вызовы у себя; общий ответ статусов воронки лежит в
tests/Frontend/support/fixtures.ts.

Заглушки повторяют то состояние, которое тесты видели при отказе сети, поэтому
ни одна проверка не переписывалась. track.spec.ts снимает общую заглушку через
vi.unmock — он проверяет сам заглушенный код.

Приёмка: сторож проверен красным трижды — обычный запрос, запрос с проглоченной
ошибкой, fetch. Полный набор интерфейса 233 файла / 1750 тестов / 3 пропущено /
0 падений / 0 ошибок. Линтер интерфейса 0, проверка типов 5 — те же
унаследованные, что были до смены. Рабочий код не тронут.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-02 17:07:07 +03:00
parent 929b0a744b
commit c5ca3c4387
12 changed files with 259 additions and 9 deletions
@@ -41,6 +41,11 @@ describe('FieldWorkspaceScreen — «Добавить вручную» объя
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [] as never;
});
// Экран при открытии тянет ДВА списка сразу (см. reload() в FieldWorkspaceScreen).
// Без этой заглушки тест физически слал запрос наружу на /api/autopodbor/proposals
// и всё равно был зелёным — ошибка вылетала мимо проверок. Ловит сторож сети
// в tests/Frontend/setup.ts.
vi.spyOn(store, 'loadProposalGroups').mockResolvedValue(undefined);
vi.spyOn(store, 'addFieldCompetitor').mockRejectedValue({
isAxiosError: true, // как настоящая ошибка axios
response: {
@@ -1,4 +1,12 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
// Карточка на открытии спрашивает дату последней смены пароля. Без заглушки тест
// физически ходил в сеть на /api/account/security и всё равно был зелёным: отказ
// глушился своим catch. Отдаём «пароль не менялся» — ровно то, что видел тест раньше.
vi.mock('../../resources/js/api/account', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/account')>()),
getAccountSecurity: async () => ({ last_password_change_at: null, sessions: [] }),
}));
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
@@ -1,4 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Экраны тянут статусы воронки с сервера. Без заглушки тест физически ходил в сеть на
// /api/lead-statuses и всё равно был зелёным: хранилище глушит отказ своим catch и
// остаётся на снимке из кода. Ловит сторож сети в tests/Frontend/setup.ts.
vi.mock('../../resources/js/api/leadStatuses', async () => {
const { otvetStatusovVoronki } = await import('./support/fixtures');
return { listLeadStatuses: async () => otvetStatusovVoronki() };
});
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
+8
View File
@@ -1,4 +1,12 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
// Экран тянет статусы воронки с сервера. Без заглушки тест физически ходил в сеть на
// /api/lead-statuses и всё равно был зелёным: хранилище глушит отказ своим catch и
// остаётся на снимке из кода. Ловит сторож сети в tests/Frontend/setup.ts.
vi.mock('../../resources/js/api/leadStatuses', async () => {
const { otvetStatusovVoronki } = await import('./support/fixtures');
return { listLeadStatuses: async () => otvetStatusovVoronki() };
});
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
@@ -1,4 +1,12 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Окно «реквизиты» на открытии читает уже сохранённые. Без заглушки тест физически
// ходил в сеть на /api/tenant/requisites и всё равно был зелёным: экран нарочно
// fail-open и глушит отказ. `null` = пустая форма — ровно то, что видел тест раньше.
vi.mock('../../resources/js/api/requisites', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/requisites')>()),
getRequisites: async () => null,
}));
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { AxiosError } from 'axios';
+9 -1
View File
@@ -1,4 +1,12 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Экран тянет статусы воронки с сервера. Без заглушки тест физически ходил в сеть на
// /api/lead-statuses и всё равно был зелёным: хранилище глушит отказ своим catch и
// остаётся на снимке из кода. Ловит сторож сети в tests/Frontend/setup.ts.
vi.mock('../../resources/js/api/leadStatuses', async () => {
const { otvetStatusovVoronki } = await import('./support/fixtures');
return { listLeadStatuses: async () => otvetStatusovVoronki() };
});
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
+8
View File
@@ -1,4 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Экран тянет статусы воронки с сервера. Без заглушки тест физически ходил в сеть на
// /api/lead-statuses и всё равно был зелёным: хранилище глушит отказ своим catch и
// остаётся на снимке из кода. Ловит сторож сети в tests/Frontend/setup.ts.
vi.mock('../../resources/js/api/leadStatuses', async () => {
const { otvetStatusovVoronki } = await import('./support/fixtures');
return { listLeadStatuses: async () => otvetStatusovVoronki() };
});
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
+20 -1
View File
@@ -1,4 +1,23 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
// Вкладки настроек на открытии тянут свои данные. Без заглушек тест физически ходил
// в сеть на /api/account/security, /api/api-keys и /api/tenants/me/webhook-settings —
// и всё равно был зелёным: отказы глушились своими catch. Отдаём «пусто», ровно то
// состояние, которое тест видел раньше. Ловит сторож сети в tests/Frontend/setup.ts.
vi.mock('../../resources/js/api/account', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/account')>()),
getAccountSecurity: async () => ({ last_password_change_at: null, sessions: [] }),
}));
vi.mock('../../resources/js/api/apiKeys', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/apiKeys')>()),
listApiKeys: async () => [],
}));
vi.mock('../../resources/js/api/webhooks', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/webhooks')>()),
getWebhookSettings: async () => null,
}));
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { createVuetify } from 'vuetify';
+120
View File
@@ -1,6 +1,10 @@
// Vitest setup для Vuetify в JSDOM-среде.
// Vuetify-компоненты используют ResizeObserver/IntersectionObserver/matchMedia,
// которые отсутствуют в JSDOM. Добавляем минимальные stub'ы.
//
// 🔴 Плюс запрет на выход в сеть — см. блок «Сторож сети» ниже.
import { afterEach, vi } from 'vitest';
class ResizeObserverStub {
observe(): void {}
@@ -49,6 +53,122 @@ if (!window.CSS || !window.CSS.supports) {
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Сторож сети: тест не имеет права ходить наружу по-настоящему.
//
// 02.08.2026. До этого дня запрета не было ни одного, и тест
// AutopodborManualCompetitorError.spec.ts физически слал XHR на
// /api/autopodbor/proposals — оставаясь при этом ЗЕЛЁНЫМ, потому что ошибка
// вылетала мимо проверок. Тот же класс разбирали 01.08 на PHP-тестах.
//
// Сторож делает две вещи, и обе нужны:
// 1) обрывает запрос — наружу ничего не уходит;
// 2) ЗАПОМИНАЕТ адрес и роняет тест в afterEach — иначе код под тестом может
// проглотить ошибку своим catch, и нарушение снова станет невидимым.
//
// Правильное лечение в тесте — заглушить вызов (vi.mock на модуль api/*
// или vi.spyOn на метод хранилища), а не отключать сторожа.
// ─────────────────────────────────────────────────────────────────────────────
const setevyeVylazki: string[] = [];
function zapomnitVylazku(adres: string): Error {
setevyeVylazki.push(adres);
return new Error(
`Тест пытается сходить в сеть по-настоящему: ${adres}. ` +
'Заглушите вызов (vi.mock модуля api/* или vi.spyOn на методе хранилища).',
);
}
const rodnoyOpen = window.XMLHttpRequest.prototype.open;
type XhrSAdresom = XMLHttpRequest & { __adresVylazki?: string };
window.XMLHttpRequest.prototype.open = function (
this: XhrSAdresom,
method: string,
url: string | URL,
...ostalnoe: unknown[]
): void {
this.__adresVylazki = `${method} ${String(url)}`;
// rodnoyOpen перегружен, точный тип аргументов здесь не выразить —
// передаём как есть.
(rodnoyOpen as (...a: unknown[]) => void).call(this, method, url, ...ostalnoe);
};
window.XMLHttpRequest.prototype.send = function (this: XhrSAdresom): void {
throw zapomnitVylazku(this.__adresVylazki ?? 'адрес неизвестен');
};
globalThis.fetch = ((vhod: unknown): never => {
const adres =
typeof vhod === 'string'
? vhod
: vhod instanceof URL
? vhod.href
: ((vhod as { url?: string })?.url ?? 'адрес неизвестен');
throw zapomnitVylazku(`fetch ${adres}`);
}) as unknown as typeof globalThis.fetch;
afterEach(() => {
if (setevyeVylazki.length === 0) return;
const spisok = [...new Set(setevyeVylazki)].join(', ');
setevyeVylazki.length = 0;
throw new Error(
`Тест ходил в сеть по-настоящему: ${spisok}. ` +
'Ошибку мог проглотить catch внутри кода — поэтому она поймана здесь, после теста.',
);
});
// ─────────────────────────────────────────────────────────────────────────────
// Фоновые вызовы оболочки — заглушены по умолчанию для ВСЕХ тестов.
//
// Боковое меню (AppSidebar) и шапка (AppLayout) при открытии сами тянут три вещи:
// счётчик сделок на бейдже, состояние автоподбора и статус баланса. Плюс роутер
// шлёт отметку о посещении. Ни один тест разметки про них не спрашивает, и все три
// вызова в рабочем коде обёрнуты в catch — то есть их отказ НЕВИДИМ.
// Именно поэтому 92 теста в 13 файлах годами ходили в сеть по-настоящему и никто
// об этом не знал (замер 02.08.2026).
//
// 🔑 Это НЕ отключение сторожа: заглушены ровно четыре фоновые точки, названные
// поимённо. Любой другой запрос сторож выше по-прежнему ловит и роняет тест.
// Тест, которому нужно другое поведение этих четырёх, объявляет свой vi.mock —
// он побеждает этот.
// ─────────────────────────────────────────────────────────────────────────────
vi.mock('../../resources/js/api/deals', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/deals')>()),
fetchDealsCount: async (): Promise<number> => 0,
}));
vi.mock('../../resources/js/api/autopodbor', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/autopodbor')>()),
fetchState: async () => ({ enabled: false, runs: [], prices: { search: '0.00', study: '0.00' } }),
}));
vi.mock('../../resources/js/api/billing', async (importOriginal) => ({
...(await importOriginal<typeof import('../../resources/js/api/billing')>()),
getBalanceStatus: async () => ({
frozen_by_balance_at: null,
balance_rub: '0.00',
capacity_leads: 0,
required_leads_per_day: 0,
deficit_leads: 0,
deficit_rub: '0.00',
}),
}));
vi.mock('../../resources/js/plugins/track', () => ({
trackVisit: () => {},
trackScreen: () => {},
trackRegisterOpen: () => {},
}));
// visualViewport — VOverlay/v-menu/v-snackbar используют для location strategies.
if (!window.visualViewport) {
Object.defineProperty(window, 'visualViewport', {
+19
View File
@@ -1,5 +1,7 @@
import type { CompetitorDto, FieldCompetitorDto, FieldSourceDto, RunDto, SourceProjectDto } from '../../../resources/js/api/autopodbor';
import type { ApiLeadStatus } from '../../../resources/js/api/leadStatuses';
import type { AutopodborCtx, AutopodborNav, ScreenName } from '../../../resources/js/views/autopodbor/nav';
import { LEAD_STATUSES } from '../../../resources/js/composables/leadStatuses';
import { reactive, ref } from 'vue';
import { vi } from 'vitest';
@@ -114,6 +116,23 @@ export function navCtx(over: Partial<AutopodborCtx> = {}): AutopodborCtx {
});
}
/**
* Ответ `GET /api/lead-statuses` — ровно те же пять системных статусов, что лежат
* снимком в коде (`LEAD_STATUSES`). Экраны «Сделки» и «Канбан» тянут их на открытии;
* до 02.08.2026 запрос уходил в сеть по-настоящему, падал, и хранилище молча
* оставалось на снимке. Заглушка отдаёт то же самое, только честно.
*/
export function otvetStatusovVoronki(): ApiLeadStatus[] {
return LEAD_STATUSES.map((s) => ({
slug: s.slug,
name_ru: s.nameRu,
is_system: s.isSystem,
sort_order: s.sortOrder,
color_hex: s.colorHex,
description: null,
}));
}
/** Навигация-заглушка: `go` — шпион, состояние полное. */
export function navStub(screen: ScreenName = 'entry', ctxOver: Partial<AutopodborCtx> = {}): AutopodborNav {
return { go: vi.fn(), ctx: navCtx(ctxOver), screen: ref<ScreenName>(screen) };
+5
View File
@@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Этот файл проверяет САМ счётчик посещений, поэтому общая заглушка из setup.ts
// здесь снимается — иначе тест проверял бы заглушку вместо рабочего кода.
// Выход в сеть тут закрыт своей подменой `fetch` ниже.
vi.unmock('../../resources/js/plugins/track');
describe('свой учёт посетителей', () => {
beforeEach(() => {
vi.resetModules();
@@ -106,13 +106,38 @@ PSR_v1, Tooling, CLAUDE.md). Файл `Tooling_v8_3.md` огромный, пра
два экрана (`FieldCompetitorScreen.vue`, `NewProjectDialog.vue`) и три теста рекламы.
Записи не мешают — хук проверку типов не гоняет.
### Е. 🌐 Тест ходит в настоящую сеть
### Е. ✅ ЗАКРЫТО 02.08.2026 — тесты интерфейса ходили в настоящую сеть
В полном прогоне тестов интерфейса печатается сетевая ошибка `ERR_NETWORK` на
`/api/autopodbor/proposals` из `tests/Frontend/AutopodborManualCompetitorError.spec.ts`.
Тест при этом зелёный, потому что ошибка вылетает мимо проверок. Это **тот же класс**, что
разбирали 01.08 на PHP-тестах: тест физически ходит наружу. Здесь адрес относительный, так
что до интернета не доходит, — но запрос не заглушен, и это стоит закрыть.
**Переделывать не надо.**
🔴 **Тест был не один.** В промте стоял один файл — тот, у которого ошибка случайно
вылезла наружу и напечаталась. Замер сторожем показал **93 теста в 14 файлах**:
остальные 92 глушились чужими `catch` внутри рабочего кода и были невидимы вообще.
Запрета на выход наружу в тестах интерфейса **не было ни одного за всю историю**
ровно как на PHP до 01.08.
**Что сделано.** В `app/tests/Frontend/setup.ts` появился сторож сети: подменяет
`XMLHttpRequest.send` и `fetch`, обрывает запрос **и** запоминает адрес, а в `afterEach`
роняет тест. Вторая половина обязательна: без неё код под тестом проглатывает ошибку
своим `catch`, и нарушение снова становится невидимым. Там же заглушены **четыре
фоновые точки оболочки**, названные поимённо (счётчик сделок, состояние автоподбора,
статус баланса, отметка о посещении) — их не спрашивает ни один тест разметки, а
отказ у всех четырёх и так проглатывается. Остальные семь файлов заглушают свои
вызовы у себя; общий ответ `GET /api/lead-statuses` лежит в
`tests/Frontend/support/fixtures.ts` (`otvetStatusovVoronki`).
🪤 **Заглушка в `setup.ts` действует на ВСЕ файлы — включая тот, что проверяет сам
заглушенный код.** `track.spec.ts` начал проверять заглушку вместо счётчика посещений.
Лечение — `vi.unmock` в самом файле, одной строкой и с объяснением.
🔑 Заглушка обязана повторять **то состояние, которое тест видел раньше** (при отказе
сети), иначе чинишь одно и ломаешь другое: пустые реквизиты, «пароль не менялся»,
те же пять статусов воронки.
Приёмка: сторож проверен красным трижды (обычный запрос, запрос с проглоченной
ошибкой, `fetch`). Полный набор интерфейса — **233 файла / 1750 тестов / 3 пропущено /
0 падений / 0 ошибок**. Линтер интерфейса 0, проверка типов **5** — те же
унаследованные, что были до смены.
### Ж. Индекс памяти
@@ -175,6 +200,9 @@ PSR_v1, Tooling, CLAUDE.md). Файл `Tooling_v8_3.md` огромный, пра
- **Долг изоляции тестов** — после всей `tests/Feature` в базе не остаётся ничего лишнего.
Сторож — `tools/storozh-chistoty-bazy.mjs`.
- **Выход тестов интерфейса в сеть** — закрыт сторожем в `app/tests/Frontend/setup.ts`
(§3 Е). Новый тест, который «почему-то не видит данных», сперва проверь на этого
сторожа: он называет адрес прямо в тексте падения.
- **Ошибка суток по всему порталу** — правило названо в `App\Support\MskBoundary`.
- **Показ времени приведён к Москве** — держит `tools/storozh-moskovskogo-vremeni.mjs`.
@@ -205,6 +233,12 @@ PSR_v1, Tooling, CLAUDE.md). Файл `Tooling_v8_3.md` огромный, пра
- 🔴🔑 **Всё, что промт говорит о БОЕВОМ состоянии, — гипотеза, пока не замерена командой.**
02.08 промт назвал два указателя на бою и велел снести не тот; на бою он был ОДИН, и
снос убил бы единственный. Промт пишется по памяти, боевое живёт своей жизнью.
- 🔑 **«Один случай» в промте — тоже не замер.** 02.08 промт называл ОДИН тест, ходящий
в сеть; сторож нашёл 93 в 14 файлах. Видно было один — тот, чью ошибку никто не
проглотил. **Считай не то, что видно, а то, что поймал датчик.**
- 🪤 **Заглушка, поставленная на всех сразу, ломает того, кто проверяет её же предмет.**
Общая заглушка в `setup.ts` заставила `track.spec.ts` проверять заглушку вместо кода.
Датчик — полный прогон; лечение — `vi.unmock` в одном файле с объяснением.
- 🔑 **Приёмка вырезанием обязательна**, обе половины: подложил поломку — остановил;
убрал — пропустил. Зелёный прогон без вырезания не доказывает ничего.
- 🪤 **Вырезание доказывает что-то, только если вырезаны ВСЕ пути к результату.** 02.08 у