mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
План уходил 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
127 lines
6.8 KiB
JavaScript
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' });
|
|
});
|