Files
houseplan-card/test/plan-upload-limit.test.mjs
T
Claude 857369b18b fix(plans): загрузка плана по HTTP, предел 8 МБ проверяется до отправки (#617)
План уходил base64 в WebSocket-кадре: файл больше ~3 МиБ давал кадр больше
4 МиБ, HA закрывал сокет до обработчика, и обещанные 8 МБ были недостижимы.

- бэкенд: HouseplanPlanUploadView (POST /api/houseplan/plans/upload), потоковый
  предел MAX_PLAN_BYTES (read_bounded), общий writer store_plan_upload для view
  и ws_plan_set (контракт WS без изменений);
- карточка: stagePlanFile/uploadPlanFile/renderPlanBackdropGuard в
  backdrop-pick.ts для обоих рантаймов; PlanFilePayload хранит Blob вместо b64;
  SVG больше предела — тост при выборе, растр — диалог #39 только с уменьшенной
  копией, копия больше предела не попадает в staging, 413 называет предел;
- i18n toast.plan_too_large, backdrop.over_limit_body (en/ru/de/fr);
  USER-GUIDE ru/en, CHANGELOG ru/en, docs/testing-notes (#617);
- тесты: test/plan-upload-limit.test.mjs, tests_backend/test_plan_upload.py,
  test_ha_upload.py (#617), smoke_plan_upload_limit.mjs; три смока переведены
  с b64/plan/set на blob/fetchWithAuth; мутанты plan-upload-*;
- база монолита: hostRefs +3 (общий хелпер плана вместо двух копий в рантаймах,
  новый тост уменьшенной копии).

Issue: #617
User-Visible: yes
2026-09-24 19:57:41 +03:00

127 lines
6.8 KiB
JavaScript

/**
* #617: предел файла плана — одно число, и карточка проверяет его до отправки.
*
* Раньше план уходил base64 внутри WebSocket-кадра: файл больше ~3 МиБ давал
* кадр больше 4 МиБ, Home Assistant закрывал сокет, а обещанные «8 МБ» были
* недостижимы. Теперь план идёт по HTTP, и предел должен совпадать везде, где
* его видит человек или проверяет код: TS-константа, `validation.py`, строка
* «План» в `USER-GUIDE.ru.md` и фраза «Plan files … up to N MB» в
* `USER-GUIDE.md` (AC6). Браузерная часть — `demo/smoke_plan_upload_limit.mjs`.
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
MAX_PLAN_BYTES, MAX_PLAN_MB, PLAN_UPLOAD_PATH, renderPlanBackdropGuard, stagePlanFile, uploadPlanFile,
} from '../test-build/backdrop-pick.js';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const pythonPlanLimit = () => {
const match = read('custom_components/houseplan/validation.py')
.match(/^MAX_PLAN_BYTES = ([\d\s*]+)$/m);
assert.ok(match, 'validation.py объявляет MAX_PLAN_BYTES произведением целых');
return match[1].split('*').map((part) => Number(part.trim())).reduce((a, b) => a * b, 1);
};
test('#617 AC6: предел плана — одно число в TS, Python и обоих USER-GUIDE', () => {
assert.equal(MAX_PLAN_BYTES, pythonPlanLimit());
assert.equal(MAX_PLAN_MB, MAX_PLAN_BYTES / 1048576);
assert.ok(Number.isInteger(MAX_PLAN_MB), 'в тексте предел — целое число МБ');
const ru = read('docs/USER-GUIDE.ru.md').match(/^\| План \| [^|]+ \| (\d+) МБ \|/m);
assert.ok(ru, 'USER-GUIDE.ru.md: строка «План» таблицы «Файлы и квоты»');
assert.equal(Number(ru[1]), MAX_PLAN_MB);
const en = read('docs/USER-GUIDE.md').match(/Plan files accept [^\n]*? up to (\d+) MB/);
assert.ok(en, 'USER-GUIDE.md: «Plan files … up to N MB»');
assert.equal(Number(en[1]), MAX_PLAN_MB);
});
const host = () => ({
toasts: [],
_t: (key, vars) => (vars ? `${key} ${JSON.stringify(vars)}` : key),
_showToast(text) { this.toasts.push(text); },
_backdropGuard: null,
requestUpdate: () => {},
});
const pngHead = (width, height, pad = 0) => {
const head = new Uint8Array(33 + pad);
head.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
head.set([0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52], 8);
new DataView(head.buffer).setUint32(16, width);
new DataView(head.buffer).setUint32(20, height);
head[24] = 8;
head[25] = 2;
return head;
};
test('#617 AC2: SVG больше предела отклоняется при выборе, тост называет предел', async () => {
const h = host();
const svg = new File([new Uint8Array(MAX_PLAN_BYTES + 1)], 'plan.svg', { type: 'image/svg+xml' });
assert.equal(await stagePlanFile(h, svg), null);
assert.deepEqual(h.toasts, [`toast.plan_too_large ${JSON.stringify({ mb: 8 })}`]);
assert.equal(h._backdropGuard, null);
});
test('#617 AC3: растр больше предела уходит в диалог #39 без «Оставить оригинал»', async () => {
const h = host();
const file = new File([pngHead(1000, 800, MAX_PLAN_BYTES + 1 - 33)], 'scan.png', { type: 'image/png' });
assert.equal(file.size, MAX_PLAN_BYTES + 1);
assert.equal(await stagePlanFile(h, file), null);
assert.equal(h.toasts.length, 0);
assert.equal(h._backdropGuard?.probe.kind, 'safe');
const keys = JSON.stringify(renderPlanBackdropGuard(h, () => {}, () => {}, {}));
assert.ok(!keys.includes('backdrop.keep_original'), 'оригинал больше предела не предлагается');
assert.ok(keys.includes('backdrop.use_downscaled'));
assert.ok(keys.includes('backdrop.over_limit_body'));
// Ровно предел — оригинал снова допустим (граница включительная).
const exact = new File([pngHead(1000, 800, MAX_PLAN_BYTES - 33)], 'scan.png', { type: 'image/png' });
h._backdropGuard = { file: exact, ext: 'png', probe: h._backdropGuard.probe, busy: false };
const exactKeys = JSON.stringify(renderPlanBackdropGuard(h, () => {}, () => {}, {}));
assert.ok(exactKeys.includes('backdrop.keep_original'));
assert.ok(!exactKeys.includes('backdrop.over_limit_body'));
});
const payload = () => ({ ext: 'png', blob: new Blob([new Uint8Array([1, 2, 3])]), aspect: 1.5, name: 'p.png' });
const t = (key, vars) => (vars ? `${key} ${JSON.stringify(vars)}` : key);
test('#617 AC1: загрузка — один POST multipart на новый маршрут, URL из ответа', async () => {
const calls = [];
const hass = {
fetchWithAuth: async (path, init) => {
calls.push({ path, init });
return { ok: true, status: 200, json: async () => ({ ok: true, url: '/api/houseplan/content/plans/_/f1.abcd.png' }) };
},
};
const out = await uploadPlanFile(hass, t, 'f1', payload());
assert.deepEqual(out, { url: '/api/houseplan/content/plans/_/f1.abcd.png' });
assert.equal(calls.length, 1);
assert.equal(calls[0].path, PLAN_UPLOAD_PATH);
assert.equal(PLAN_UPLOAD_PATH, '/api/houseplan/plans/upload');
const body = calls[0].init.body;
assert.equal(calls[0].init.method, 'POST');
assert.equal(body.get('space_id'), 'f1');
assert.equal(body.get('ext'), 'png');
assert.deepEqual([...new Uint8Array(await body.get('file').arrayBuffer())], [1, 2, 3]);
});
test('#617 AC5: ответ 413 превращается в текст с пределом, а не в «HTTP 413»', async () => {
const answer = (status, json) => ({
fetchWithAuth: async () => ({ ok: false, status, json: async () => { if (json === null) throw new Error('not json'); return json; } }),
});
await assert.rejects(uploadPlanFile(answer(413, { error: 'too_large', max_mb: 8 }), t, 'f1', payload()),
{ message: `err.too_large ${JSON.stringify({ mb: 8 })}` });
// прокси перед HA отвечает 413 без JSON — предел берётся из константы
await assert.rejects(uploadPlanFile(answer(413, null), t, 'f1', payload()),
{ message: `err.too_large ${JSON.stringify({ mb: MAX_PLAN_MB })}` });
await assert.rejects(uploadPlanFile(answer(403, { error: 'unauthorized' }), t, 'f1', payload()),
{ message: 'err.unauthorized' });
await assert.rejects(uploadPlanFile(answer(507, { error: 'too_many_files', detail: 'x' }), t, 'f1', payload()),
{ message: 'too_many_files' });
await assert.rejects(uploadPlanFile(answer(404, null), t, 'f1', payload()), { message: 'HTTP 404' });
});