Files
enduro-trails/tests/web/track_download.test.js
claude-bot 721b33a2f6
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / lint (pull_request) Successful in 4s
CI / build (push) Successful in 2s
CI / test (pull_request) Successful in 6s
CI / build (pull_request) Successful in 4s
fix(gps-tracks): address ET-011 review — JS UI tests + flat 403 contract
Закрывает findings из docs/work-items/ET-011/12-review.md (REQUEST_CHANGES,
попытка 3/3):

P1-01 — добавлены поведенческие JS unit-тесты UI download-flow
  - tests/web/track_download.test.js — 28 кейсов (node --test):
      • _parseFilenameFromCD — RFC 5987 приоритет, plain fallback,
        битый percent-encoding, null/empty (REQ-F-05.2, AC-2 UI)
      • _handleDownloadError — 400/403/404/413/5xx тосты, defensive
        при отсутствии showToast, поддержка flat (ADR-015 §G) и legacy
        wrapped 403-форм (REQ-F-05.4, AC-7 UI)
      • _renderTrackPopupHtml — наличие кнопки, aria-label «Скачать GPX»,
        data-track-id, отсутствие при невалидном id, регрессия прочих
        полей (REQ-F-01, AC-1)
  - tests/web/test_track_download.py — pytest-обёртка (статические
    проверки + запуск Node-раннера), исполняется в обычном pytest tests/
  - 04b-ui-test-cases.md: AC-13 (mobile-bbox) явно маркирован как
    MANUAL release-smoke (Playwright-раннер в проекте не настроен;
    альтернатива согласована reviewer'ом в P1-01).

P2-01 — устранено расхождение «doc vs runtime» по контракту 403
  - endpoint.py: HTTPException(detail={...}) → JSONResponse(content={...}),
    чтобы FastAPI не оборачивал dict во второй слой «detail». Контракт
    теперь совпадает с ADR-015 §G и ADR-014 §6:
        {"detail":"source_forbidden","external_urls":[...]}
  - test_gps_tracks_download.py IT-05: упрощено — body уже плоский,
    без двухуровневого `body.get("detail", body)` workaround.
  - gps_tracks.js::_handleDownloadError: flat-форма стала приоритетной,
    wrapped-форма оставлена как defensive fallback (с комментарием).

Регрессия: 89/89 API-тестов + 24/24 предыдущих JS-тестов + 28 новых
JS-тестов download-flow проходят. ruff check — clean.

Refs: ET-011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 23:01:19 +00:00

360 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* ET-011 — поведенческие JS unit-тесты UI для скачивания GPX из popup
* публичного трека (src/web/gps_tracks.js).
*
* Контекст: test-plan §E2E-01..E2E-04 предусматривал Playwright-спеку
* (`tests/web/test_track_download.spec.ts`), но в проекте нет настроенного
* Playwright-раннера. Reviewer ET-011 (12-review.md, P1-01) явно разрешил
* закрыть UI-сторону AC-1 / AC-2 / AC-7 этими JS unit-тестами, оставив
* AC-13 (mobile-bbox) как manual smoke (см. 04b-ui-test-cases.md TC-UI-02).
*
* Покрываются:
* - _parseFilenameFromCD — REQ-F-05.2, AC-2 (UI-чтение хедера)
* - _handleDownloadError — REQ-F-05.4, AC-7 (toast по статусу)
* - _renderTrackPopupHtml — REQ-F-01, AC-1 (кнопка в popup,
* aria-label, тапабельный data-track-id)
*
* Запуск: node --test tests/web/track_download.test.js
* В CI оборачивается pytest-тестом tests/web/test_track_download.py.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const GPS_TRACKS_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'gps_tracks.js');
// ─── Загрузчик модуля ─────────────────────────────────────────────────────────
/**
* Загружает gps_tracks.js в изолированный контекст new Function, подставляя
* мок-объекты вместо браузерных глобалов (`window`, `document`, `showToast`).
*
* Возвращает приватные функции, требуемые в тестах:
* _parseFilenameFromCD, _handleDownloadError, _renderTrackPopupHtml.
*
* @param {object} [opts]
* @param {object} [opts.win] мок window
* @param {object} [opts.doc] мок document
* @param {Function|null} [opts.showToast] мок showToast (null → отсутствует)
* @returns {{
* _parseFilenameFromCD: Function,
* _handleDownloadError: Function,
* _renderTrackPopupHtml: Function,
* }}
*/
function loadDownloadModule(opts) {
const o = opts || {};
const win = o.win || {};
win.localStorage = win.localStorage || {
getItem: () => null,
setItem: () => {},
};
const doc = o.doc || {
getElementById: () => null,
querySelectorAll: () => ({ forEach: () => {} }),
};
// showToast === undefined → typeof === 'undefined' → ранний return в
// _handleDownloadError (defensive). null → typeof === 'object' → тоже return.
const showToast = Object.prototype.hasOwnProperty.call(o, 'showToast')
? o.showToast
: undefined;
const src = fs.readFileSync(GPS_TRACKS_PATH, 'utf8');
const factory = new Function(
'window', 'document', 'showToast',
src +
'\nreturn {' +
' _parseFilenameFromCD,' +
' _handleDownloadError,' +
' _renderTrackPopupHtml,' +
'};'
);
return factory(win, doc, showToast);
}
// ═══════════════════════════════════════════════════════════════════════════
// _parseFilenameFromCD — RFC 5987 + plain filename
// ═══════════════════════════════════════════════════════════════════════════
test('CD: null → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD(null), null);
});
test('CD: undefined → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD(undefined), null);
});
test('CD: пустая строка → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD(''), null);
});
test('CD: без параметров filename → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD('attachment'), null);
});
test('CD: plain filename="track.gpx" → "track.gpx"', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(
_parseFilenameFromCD('attachment; filename="track.gpx"'),
'track.gpx',
);
});
test('CD: plain filename без кавычек → значение до ; ', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(
_parseFilenameFromCD('attachment; filename=track.gpx'),
'track.gpx',
);
});
test('CD: filename*=UTF-8\'\'<percent> приоритетнее plain filename (RFC 5987)', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
// backend всегда отдаёт оба параметра; для не-ASCII имени берётся star
const cd = 'attachment; filename="track-1.gpx"; '
+ "filename*=UTF-8''%D0%9F%D0%BE%20%D0%B3%D1%80%D1%8F%D0%B7%D0%B8.gpx";
assert.equal(_parseFilenameFromCD(cd), 'По грязи.gpx');
});
test('CD: filename* с битым percent-encoding → fallback на plain filename', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
// %ZZ — невалидный percent (decodeURIComponent кинет)
const cd = 'attachment; filename="track-1.gpx"; '
+ "filename*=UTF-8''%ZZbroken.gpx";
assert.equal(_parseFilenameFromCD(cd), 'track-1.gpx');
});
test('CD: filename* без последующего ; (конец строки) — декодируется до конца', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
// ADR-014 §F: backend кладёт filename* последним параметром
const cd = "attachment; filename=\"a.gpx\"; filename*=UTF-8''%D0%90.gpx";
assert.equal(_parseFilenameFromCD(cd), 'А.gpx');
});
// ═══════════════════════════════════════════════════════════════════════════
// _handleDownloadError — REQ-F-05.4, AC-7
// ═══════════════════════════════════════════════════════════════════════════
/** Создаёт мок showToast, копящий последние вызовы. */
function makeToastSpy() {
const calls = [];
const fn = (msg) => { calls.push(msg); };
return { fn, calls };
}
test('Error: 404 → toast «Трек не найден.»', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(404, {});
assert.equal(spy.calls.length, 1);
assert.equal(spy.calls[0], 'Трек не найден.');
});
test('Error: 413 → toast про размер', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(413, {});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /слишком большой/i);
});
test('Error: 400 → toast про формат', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(400, {});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /формат/i);
});
test('Error: 500 / unknown → дефолтный toast «Не удалось скачать.»', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(500, {});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /Не удалось скачать/);
});
test('Error: 403 с external_urls (ADR-015 §G flat-форма) → toast с URL', () => {
// ADR-015 §G: backend → JSONResponse{detail, external_urls} (без вложенности).
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, {
detail: 'source_forbidden',
external_urls: ['https://www.wikiloc.com/abc'],
});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /Источник запрещает/);
assert.ok(
spy.calls[0].includes('https://www.wikiloc.com/abc'),
`toast должен содержать external_url, было: ${spy.calls[0]}`,
);
});
test('Error: 403 с body.detail.external_urls (legacy wrapped-форма) → URL читается', () => {
// Defensive fallback на старую форму до P2-01 (когда HTTPException
// оборачивал detail в {"detail": {...}}). Тест защищает frontend от
// регресса, если кто-то восстановит HTTPException-вариант.
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, {
detail: {
detail: 'source_forbidden',
external_urls: ['https://wikiloc.com/x'],
},
});
assert.equal(spy.calls.length, 1);
assert.ok(
spy.calls[0].includes('https://wikiloc.com/x'),
`toast должен содержать external_url из legacy формы, было: ${spy.calls[0]}`,
);
});
test('Error: 403 без external_urls → toast без URL', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, { detail: 'source_forbidden' });
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /Источник запрещает/);
// в сообщении не должно быть http-URL
assert.ok(
!/https?:\/\//.test(spy.calls[0]),
`toast не должен содержать URL когда external_urls пуст, было: ${spy.calls[0]}`,
);
});
test('Error: 403 с external_urls = [] → toast без URL', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, { detail: 'source_forbidden', external_urls: [] });
assert.equal(spy.calls.length, 1);
assert.ok(!/https?:\/\//.test(spy.calls[0]));
});
test('Error: showToast отсутствует → не падаем (defensive)', () => {
// showToast не передан → typeof === 'undefined' → ранний return
const { _handleDownloadError } = loadDownloadModule();
assert.doesNotThrow(() => _handleDownloadError(404, {}));
assert.doesNotThrow(() => _handleDownloadError(403, { external_urls: ['x'] }));
assert.doesNotThrow(() => _handleDownloadError(500, {}));
});
// ═══════════════════════════════════════════════════════════════════════════
// _renderTrackPopupHtml — REQ-F-01, AC-1
// ═══════════════════════════════════════════════════════════════════════════
test('Popup: при валидном числовом id рендерится кнопка «Скачать GPX»', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 42,
name: 'Test Trail',
activity_type: 'enduro',
length_km: 5.3,
points_count: 100,
});
// AC-1: aria-label «Скачать GPX» обязателен (REQ-F-01)
assert.match(html, /aria-label="Скачать GPX"/);
// структура / классы для CSS (.track-popup-download-btn — ADR-014 §3.a)
assert.match(html, /class="track-popup-download-btn"/);
assert.match(html, /<div class="track-popup-actions">/);
// data-track-id — для делегированного обработчика (ADR-014 §3.b)
assert.match(html, /data-track-id="42"/);
// SVG download-иконка
assert.match(html, /<svg [^>]*viewBox="0 0 24 24"/);
});
test('Popup: id в виде строки "7" тоже даёт кнопку (Number() приводит)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: '7',
name: 'Test',
});
assert.match(html, /data-track-id="7"/);
assert.match(html, /aria-label="Скачать GPX"/);
});
test('Popup: id = 0 → кнопка НЕ рендерится (Path int ge=1 на бэке)', () => {
// backend требует ge=1; защищаем frontend от запроса /download/0
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 0,
name: 'Test',
});
assert.doesNotMatch(html, /track-popup-download-btn/);
assert.doesNotMatch(html, /Скачать GPX/);
});
test('Popup: id = null → кнопка НЕ рендерится', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: null, name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup: id отсутствует → кнопка НЕ рендерится', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup: id = "abc" (мусор) → кнопка НЕ рендерится', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: 'abc', name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup: id = -1 → кнопка НЕ рендерится (защита от patho-кейсов)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: -1, name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup-регрессия: остаются прежние поля (имя, активность, длина)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 1,
name: 'Озеро',
activity_type: 'enduro',
length_km: 12.5,
points_count: 250,
user: 'tester',
created_at: '2024-05-01T00:00:00Z',
});
assert.match(html, /<div class="track-popup-name">Озеро<\/div>/);
assert.match(html, /Эндуро/); // GPS_ACTIVITY_LABELS.enduro
assert.match(html, /12\.5 км/);
assert.match(html, /250 точек/);
assert.match(html, /tester/);
});
test('Popup: actionsHtml идёт ПЕРЕД sourcesHtml (ADR-014 §3.a)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 9,
name: 'X',
sources: ['osm'],
external_urls: ['https://www.openstreetmap.org/way/9'],
});
const idxActions = html.indexOf('track-popup-actions');
const idxSources = html.indexOf('track-popup-sources');
assert.notEqual(idxActions, -1, 'actionsHtml присутствует');
assert.notEqual(idxSources, -1, 'sourcesHtml присутствует');
assert.ok(
idxActions < idxSources,
'actionsHtml (кнопка) должен идти раньше sourcesHtml',
);
});
test('Popup: без источников всё равно рендерится кнопка (если id ок)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: 3, name: 'NoSources' });
assert.match(html, /track-popup-download-btn/);
assert.doesNotMatch(html, /track-popup-sources/);
});