@escapenavigator/utils 1.10.133 → 1.10.134
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/email-builder/from-html.d.ts +149 -0
- package/dist/email-builder/from-html.js +1018 -0
- package/dist/email-builder/from-html.test.d.ts +1 -0
- package/dist/email-builder/from-html.test.js +753 -0
- package/dist/email-builder/placeholders.d.ts +89 -0
- package/dist/email-builder/placeholders.js +176 -0
- package/dist/email-builder/strip-html.d.ts +31 -0
- package/dist/email-builder/strip-html.js +73 -0
- package/dist/email-builder/strip-html.test.d.ts +1 -0
- package/dist/email-builder/strip-html.test.js +99 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sanitize-quill-html.d.ts +16 -0
- package/dist/sanitize-quill-html.js +17 -3
- package/dist/sanitize-quill-html.test.d.ts +8 -0
- package/dist/sanitize-quill-html.test.js +71 -0
- package/dist/strip-html-tags.d.ts +13 -0
- package/dist/strip-html-tags.js +24 -0
- package/package.json +4 -3
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const email_builder_1 = require("@escapenavigator/types/dist/email-builder");
|
|
4
|
+
const from_html_1 = require("./from-html");
|
|
5
|
+
/**
|
|
6
|
+
* Хелпер: достаёт `doc.content` без верхнего `image` (логотип) — он
|
|
7
|
+
* в тестах редко интересен и только засоряет матчеры. Если логотип
|
|
8
|
+
* не передавался — возвращает массив как есть.
|
|
9
|
+
*/
|
|
10
|
+
const docBody = (result) => {
|
|
11
|
+
const content = result.doc.content || [];
|
|
12
|
+
if (content[0] && content[0].type === 'image') {
|
|
13
|
+
return content.slice(1);
|
|
14
|
+
}
|
|
15
|
+
return content;
|
|
16
|
+
};
|
|
17
|
+
describe('convertLegacyHtmlToEmailContent', () => {
|
|
18
|
+
describe('базовый каркас', () => {
|
|
19
|
+
it('возвращает валидный v2-doc для пустого ввода', () => {
|
|
20
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('');
|
|
21
|
+
expect(result.version).toBe(email_builder_1.EMAIL_CONTENT_VERSION_V2);
|
|
22
|
+
expect(result.doc.type).toBe('doc');
|
|
23
|
+
expect(result.doc.content).toEqual([]);
|
|
24
|
+
expect(result.theme).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
it('возвращает валидный v2-doc для null/undefined', () => {
|
|
27
|
+
const a = (0, from_html_1.convertLegacyHtmlToEmailContent)(null);
|
|
28
|
+
const b = (0, from_html_1.convertLegacyHtmlToEmailContent)(undefined);
|
|
29
|
+
expect(a.doc.content).toEqual([]);
|
|
30
|
+
expect(b.doc.content).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
it('вставляет logo-ноду первой если задан logo', () => {
|
|
33
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Привет</p>', {
|
|
34
|
+
logo: 'https://cdn.example.com/logo.png',
|
|
35
|
+
});
|
|
36
|
+
const content = result.doc.content || [];
|
|
37
|
+
expect(content[0]).toMatchObject({
|
|
38
|
+
type: 'image',
|
|
39
|
+
attrs: { src: 'https://cdn.example.com/logo.png', alignment: 'left' },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
it('не вставляет logo-ноду если не задан', () => {
|
|
43
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Привет</p>');
|
|
44
|
+
const content = result.doc.content || [];
|
|
45
|
+
expect(content[0].type).toBe('paragraph');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('параграфы и текст', () => {
|
|
49
|
+
it('конвертит один параграф', () => {
|
|
50
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Привет, мир!</p>');
|
|
51
|
+
expect(docBody(result)).toEqual([
|
|
52
|
+
{
|
|
53
|
+
type: 'paragraph',
|
|
54
|
+
attrs: { textAlign: 'left' },
|
|
55
|
+
content: [{ type: 'text', text: 'Привет, мир!' }],
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
it('конвертит несколько параграфов', () => {
|
|
60
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Один</p><p>Два</p>');
|
|
61
|
+
const body = docBody(result);
|
|
62
|
+
expect(body).toHaveLength(2);
|
|
63
|
+
expect(body[0].content).toEqual([
|
|
64
|
+
{ type: 'text', text: 'Один' },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
it('схлопывает пустые параграфы <p></p>', () => {
|
|
68
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>А</p><p></p><p>Б</p>');
|
|
69
|
+
expect(docBody(result)).toHaveLength(2);
|
|
70
|
+
});
|
|
71
|
+
it('схлопывает <p><br></p>', () => {
|
|
72
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>А</p><p><br></p><p>Б</p>');
|
|
73
|
+
expect(docBody(result)).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
it('конвертит <br> в hardBreak в составе параграфа', () => {
|
|
76
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Один<br>Два</p>');
|
|
77
|
+
const body = docBody(result);
|
|
78
|
+
expect(body[0].content).toEqual([
|
|
79
|
+
{ type: 'text', text: 'Один' },
|
|
80
|
+
{ type: 'hardBreak' },
|
|
81
|
+
{ type: 'text', text: 'Два' },
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
it('декодирует HTML-сущности и nbsp', () => {
|
|
85
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Кавычки: "тест" & nbsp: конец</p>');
|
|
86
|
+
const body = docBody(result);
|
|
87
|
+
expect(body[0].content[0].text).toBe('Кавычки: "тест" & nbsp: конец');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('inline-форматирование', () => {
|
|
91
|
+
it('конвертит <strong> в bold mark', () => {
|
|
92
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Обычный <strong>жирный</strong> текст</p>');
|
|
93
|
+
const body = docBody(result);
|
|
94
|
+
const content = body[0].content;
|
|
95
|
+
expect(content).toEqual([
|
|
96
|
+
{ type: 'text', text: 'Обычный ' },
|
|
97
|
+
{ type: 'text', text: 'жирный', marks: [{ type: 'bold' }] },
|
|
98
|
+
{ type: 'text', text: ' текст' },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
it('конвертит <em>/<i> в italic, <u> в underline, <s> в strike', () => {
|
|
102
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p><em>a</em><i>b</i><u>c</u><s>d</s></p>');
|
|
103
|
+
const content = docBody(result)[0].content;
|
|
104
|
+
expect(content.map((n) => n.marks[0].type)).toEqual([
|
|
105
|
+
'italic',
|
|
106
|
+
'italic',
|
|
107
|
+
'underline',
|
|
108
|
+
'strike',
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
it('накапливает marks при вложенности', () => {
|
|
112
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p><strong><em>оба</em></strong></p>');
|
|
113
|
+
const content = docBody(result)[0].content;
|
|
114
|
+
expect(content[0].marks.map((m) => m.type).sort()).toEqual(['bold', 'italic']);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('ссылки', () => {
|
|
118
|
+
it('конвертит <a> в link-mark с href/target/rel', () => {
|
|
119
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Жми <a href="https://example.com" target="_blank">тут</a>.</p>');
|
|
120
|
+
const content = docBody(result)[0].content;
|
|
121
|
+
const linkNode = content.find((n) => n.marks?.[0]?.type === 'link');
|
|
122
|
+
expect(linkNode).toBeDefined();
|
|
123
|
+
expect(linkNode?.marks?.[0].attrs).toMatchObject({
|
|
124
|
+
href: 'https://example.com',
|
|
125
|
+
target: '_blank',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('плоские плейсхолдеры {var}', () => {
|
|
130
|
+
it('превращает {clientName} в variable-ноду (с маппингом в client.firstName)', () => {
|
|
131
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Привет, {clientName}!</p>', {
|
|
132
|
+
labels: { 'client.firstName': 'Имя клиента' },
|
|
133
|
+
});
|
|
134
|
+
const content = docBody(result)[0].content;
|
|
135
|
+
expect(content).toEqual([
|
|
136
|
+
{ type: 'text', text: 'Привет, ' },
|
|
137
|
+
{
|
|
138
|
+
type: 'variable',
|
|
139
|
+
attrs: {
|
|
140
|
+
id: 'client.firstName',
|
|
141
|
+
label: 'Имя клиента',
|
|
142
|
+
fallback: null,
|
|
143
|
+
required: false,
|
|
144
|
+
hideDefaultValue: false,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{ type: 'text', text: '!' },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
it('использует normalized id как label, если labels не передан', () => {
|
|
151
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{questroomTitle}</p>');
|
|
152
|
+
const content = docBody(result)[0]
|
|
153
|
+
.content;
|
|
154
|
+
expect(content[0].attrs.label).toBe('questroom.title');
|
|
155
|
+
});
|
|
156
|
+
it('обрабатывает несколько токенов в одном параграфе (id маппятся)', () => {
|
|
157
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{clientName} на {date} в {address}</p>');
|
|
158
|
+
const content = docBody(result)[0].content;
|
|
159
|
+
const vars = content.filter((n) => n.type === 'variable').map((n) => n.attrs?.id);
|
|
160
|
+
expect(vars).toEqual(['client.firstName', 'order.date', 'location.address']);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe('maily-style плейсхолдеры {{var}}', () => {
|
|
164
|
+
it('{{profileTitle}} без inlineValues остаётся как `variable` без маппинга', () => {
|
|
165
|
+
/*
|
|
166
|
+
* `profileTitle` намеренно ВЫПИЛЕН из `LEGACY_TO_NAMESPACED_ID_MAP`:
|
|
167
|
+
* пользователь редактирует название компании руками прямо в
|
|
168
|
+
* шаблоне (мы убрали `profile.title` из declare()-стека всех
|
|
169
|
+
* скоупов). Без inlineValues конвертер оставляет токен как
|
|
170
|
+
* `variable`-ноду с flat-id `profileTitle` — рендер на проде
|
|
171
|
+
* вернёт пустую строку, и юзер увидит «дыру», после чего
|
|
172
|
+
* вручную заменит на нужный текст.
|
|
173
|
+
*/
|
|
174
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Команда {{profileTitle}}</p>');
|
|
175
|
+
const content = docBody(result)[0].content;
|
|
176
|
+
const v = content.find((n) => n.type === 'variable');
|
|
177
|
+
expect(v?.attrs?.id).toBe('profileTitle');
|
|
178
|
+
});
|
|
179
|
+
it('{{profileTitle}} с inlineValues заменяется на text-ноду с буквальным значением', () => {
|
|
180
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Команда {{profileTitle}}</p>', {
|
|
181
|
+
inlineValues: { profileTitle: 'Escape Quest Moscow' },
|
|
182
|
+
});
|
|
183
|
+
const content = docBody(result)[0].content;
|
|
184
|
+
// Один text-узел со склеенным контентом — паттерн backfill'а
|
|
185
|
+
// в `AdminMarketingEmailsRefillService`.
|
|
186
|
+
expect(content.every((n) => n.type === 'text')).toBe(true);
|
|
187
|
+
expect(content.map((n) => n.text).join('')).toBe('Команда Escape Quest Moscow');
|
|
188
|
+
});
|
|
189
|
+
it('поддерживает inline-fallback {{X|"default"}}, маппит id', () => {
|
|
190
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{{clientName|"гость"}}</p>');
|
|
191
|
+
const content = docBody(result)[0].content;
|
|
192
|
+
expect(content[0].attrs).toMatchObject({ id: 'client.firstName', fallback: 'гость' });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe('IF-блоки {{#if X}}…{{/if}}', () => {
|
|
196
|
+
it('оборачивает содержимое в section с showIfKey', () => {
|
|
197
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>А</p>{{#if promocode}}<p>Промо: {promocode}</p>{{/if}}<p>Б</p>');
|
|
198
|
+
const body = docBody(result);
|
|
199
|
+
const section = body.find((n) => n.type === 'section');
|
|
200
|
+
expect(section).toBeDefined();
|
|
201
|
+
expect(section.attrs.showIfKey).toBe('promocode.code');
|
|
202
|
+
expect(section.content).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
it('распаковывает IF, разорванный по нескольким параграфам (как в crm-crosssales.json)', () => {
|
|
205
|
+
// Реальный кейс из переводов: открывающий и закрывающий
|
|
206
|
+
// {{#if}}/{{/if}} лежат каждый в своём <p></p>
|
|
207
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{{#if howToFindLocation}}</p><p>{howToFindLocation}</p><p>{{/if}}</p>');
|
|
208
|
+
const body = docBody(result);
|
|
209
|
+
const section = body.find((n) => n.type === 'section');
|
|
210
|
+
expect(section).toBeDefined();
|
|
211
|
+
expect(section.attrs.showIfKey).toBe('location.howToFind');
|
|
212
|
+
// Внутри секции — параграф с variable нодой
|
|
213
|
+
const innerParagraph = section.content[0];
|
|
214
|
+
expect(innerParagraph.content[0].type).toBe('variable');
|
|
215
|
+
});
|
|
216
|
+
it('поддерживает вложенные IF-блоки', () => {
|
|
217
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('{{#if A}}<p>A</p>{{#if B}}<p>B</p>{{/if}}{{/if}}');
|
|
218
|
+
const body = docBody(result);
|
|
219
|
+
const outer = body[0];
|
|
220
|
+
expect(outer.type).toBe('section');
|
|
221
|
+
expect(outer.attrs.showIfKey).toBe('A');
|
|
222
|
+
const inner = outer.content.find((n) => n.type === 'section');
|
|
223
|
+
expect(inner?.attrs?.showIfKey).toBe('B');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe('кнопка бронирования {{#manageBooking}}…{{/manageBooking}}', () => {
|
|
227
|
+
it('превращается в button-ноду с isUrlVariable=true', () => {
|
|
228
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{{#manageBooking}}Управлять бронью{{/manageBooking}}</p>');
|
|
229
|
+
const body = docBody(result);
|
|
230
|
+
// Кнопка извлекается из inline-контекста как самостоятельный блок —
|
|
231
|
+
// см. собирание content в parent <p>. Здесь её формат проверяем.
|
|
232
|
+
const findButton = (nodes) => {
|
|
233
|
+
for (const n of nodes) {
|
|
234
|
+
if (n.type === 'button')
|
|
235
|
+
return n;
|
|
236
|
+
const children = n.content;
|
|
237
|
+
if (children) {
|
|
238
|
+
const found = findButton(children);
|
|
239
|
+
if (found)
|
|
240
|
+
return found;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
};
|
|
245
|
+
const button = findButton(body);
|
|
246
|
+
expect(button).toBeDefined();
|
|
247
|
+
expect(button.attrs).toMatchObject({
|
|
248
|
+
text: 'Управлять бронью',
|
|
249
|
+
url: 'bookingManagementLink',
|
|
250
|
+
isUrlVariable: true,
|
|
251
|
+
});
|
|
252
|
+
// Дефолты variant/alignment/borderRadius/padding явно НЕ
|
|
253
|
+
// зашиваем — Maily-button-extension сам подставит их через
|
|
254
|
+
// schema (left/filled/smooth/null), и bubble-menu toolbar
|
|
255
|
+
// тогда корректно `updateAttributes`-ит при кликах юзера.
|
|
256
|
+
expect(button.attrs.alignment).toBeUndefined();
|
|
257
|
+
expect(button.attrs.paddingTop).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
describe('блочные HTML-плейсхолдеры (orderDetailsHtml/photos)', () => {
|
|
261
|
+
/*
|
|
262
|
+
* Block-payload канал выпилен (см. `BLOCK_HTML_PLACEHOLDERS = {}`
|
|
263
|
+
* в `placeholders.ts`). Legacy-плейсхолдеры теперь:
|
|
264
|
+
*
|
|
265
|
+
* - `photos` — конвертится в редактируемую `image`-ноду с
|
|
266
|
+
* `src: 'photos'` (см. отдельный test ниже);
|
|
267
|
+
* - `orderDetailsHtml` — на проде не использовался, оставляем
|
|
268
|
+
* как обычную `variable`-ноду (на рендере получит пустую
|
|
269
|
+
* строку, пользователь увидит дыру и поправит шаблон).
|
|
270
|
+
*
|
|
271
|
+
* (`servicesList` выпилен целиком — отдельный describe ниже
|
|
272
|
+
* проверяет, что variable-чип и обёртка-параграф удаляются.)
|
|
273
|
+
*
|
|
274
|
+
* Эти тесты следят, чтобы у нас НЕ появился случайно
|
|
275
|
+
* htmlCodeBlock-promote обратно (мы его удалили).
|
|
276
|
+
*/
|
|
277
|
+
it('{orderDetailsHtml} остаётся inline variable-нодой (block-promote выпилен)', () => {
|
|
278
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Заказ:</p><p>{orderDetailsHtml}</p><p>Спасибо</p>');
|
|
279
|
+
const body = docBody(result);
|
|
280
|
+
// Никаких htmlCodeBlock — только три параграфа подряд.
|
|
281
|
+
expect(body.every((n) => n.type === 'paragraph')).toBe(true);
|
|
282
|
+
const middleParagraph = body[1];
|
|
283
|
+
expect(middleParagraph.content[0].type).toBe('variable');
|
|
284
|
+
expect(middleParagraph.content[0].attrs?.id).toBe('orderDetailsHtml');
|
|
285
|
+
});
|
|
286
|
+
it('inline {orderDetailsHtml} не разрывает родительский параграф', () => {
|
|
287
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Заказ: {orderDetailsHtml} спасибо за бронь.</p>');
|
|
288
|
+
const body = docBody(result);
|
|
289
|
+
// Раньше делили на paragraph + htmlCodeBlock + paragraph.
|
|
290
|
+
// Теперь — один параграф, переменная внутри inline.
|
|
291
|
+
expect(body).toHaveLength(1);
|
|
292
|
+
expect(body[0].type).toBe('paragraph');
|
|
293
|
+
});
|
|
294
|
+
it('оставляет {website} как inline variable (не блочный)', () => {
|
|
295
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Сайт: {website}</p>');
|
|
296
|
+
const body = docBody(result);
|
|
297
|
+
// Один параграф, htmlCodeBlock не порождаем
|
|
298
|
+
expect(body).toHaveLength(1);
|
|
299
|
+
expect(body[0].type).toBe('paragraph');
|
|
300
|
+
const content = body[0].content;
|
|
301
|
+
expect(content.find((n) => n.type === 'variable')?.attrs?.id).toBe('location.site');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
describe('изображения', () => {
|
|
305
|
+
it('конвертит <img> в image-ноду', () => {
|
|
306
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Фото:</p><img src="https://cdn.example.com/p.jpg" alt="Фото"/>');
|
|
307
|
+
const body = docBody(result);
|
|
308
|
+
const image = body.find((n) => n.type === 'image');
|
|
309
|
+
expect(image).toBeDefined();
|
|
310
|
+
expect(image.attrs).toMatchObject({
|
|
311
|
+
src: 'https://cdn.example.com/p.jpg',
|
|
312
|
+
alt: 'Фото',
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
describe('headings (<h1>/<h2>/<h3>)', () => {
|
|
318
|
+
it('конвертит <h3>X</h3> в heading-ноду с level=3', () => {
|
|
319
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<h3>Дата игры:</h3><p>{date}</p>');
|
|
320
|
+
const body = docBody(result);
|
|
321
|
+
expect(body[0].type).toBe('heading');
|
|
322
|
+
expect(body[0].attrs).toMatchObject({ level: 3, textAlign: 'left' });
|
|
323
|
+
expect(body[0].content?.[0].text).toBe('Дата игры:');
|
|
324
|
+
// следующий параграф остался обычным
|
|
325
|
+
expect(body[1].type).toBe('paragraph');
|
|
326
|
+
});
|
|
327
|
+
it('поддерживает уровни 1/2/3', () => {
|
|
328
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<h1>A</h1><h2>B</h2><h3>C</h3>');
|
|
329
|
+
const body = docBody(result);
|
|
330
|
+
expect(body.map((n) => [n.type, n.attrs?.level])).toEqual([
|
|
331
|
+
['heading', 1],
|
|
332
|
+
['heading', 2],
|
|
333
|
+
['heading', 3],
|
|
334
|
+
]);
|
|
335
|
+
});
|
|
336
|
+
it('переменные внутри heading маппятся через idMap', () => {
|
|
337
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<h3>{questroomTitle}</h3>');
|
|
338
|
+
const body = docBody(result);
|
|
339
|
+
expect(body[0].type).toBe('heading');
|
|
340
|
+
expect(body[0].content?.[0].attrs?.id).toBe('questroom.title');
|
|
341
|
+
});
|
|
342
|
+
it('пустой <h3></h3> отбрасывается (как пустой параграф)', () => {
|
|
343
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<h3></h3><p>X</p>');
|
|
344
|
+
const body = docBody(result);
|
|
345
|
+
expect(body).toHaveLength(1);
|
|
346
|
+
expect(body[0].type).toBe('paragraph');
|
|
347
|
+
});
|
|
348
|
+
it('<h4>-<h6> уходят в transparent (Maily их не поддерживает)', () => {
|
|
349
|
+
// Текст внутри h4 должен попасть в выход как обычный параграф
|
|
350
|
+
// (transparent fallback раскрывает содержимое).
|
|
351
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<h4>X</h4>');
|
|
352
|
+
const body = docBody(result);
|
|
353
|
+
// Не должно быть heading-ноды
|
|
354
|
+
expect(body.every((n) => n.type !== 'heading')).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
describe('маппинг flat-id\u0432 в namespaced (LEGACY_TO_NAMESPACED_ID_MAP)', () => {
|
|
358
|
+
it('маппит {clientName} в client.firstName в variable-ноде', () => {
|
|
359
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Привет, {clientName}!</p>');
|
|
360
|
+
const body = (result.doc.content || []);
|
|
361
|
+
const v = body[0]?.content?.find((n) => n.attrs?.id);
|
|
362
|
+
expect(v?.attrs?.id).toBe('client.firstName');
|
|
363
|
+
});
|
|
364
|
+
it('маппит все известные legacy-id-в namespaced', () => {
|
|
365
|
+
const html = '<p>{questroomTitle} {discount} {website} {phone}</p>';
|
|
366
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)(html);
|
|
367
|
+
const ids = ((result.doc.content?.[0]).content ||
|
|
368
|
+
[])
|
|
369
|
+
.filter((n) => n.attrs?.id)
|
|
370
|
+
.map((n) => n.attrs.id);
|
|
371
|
+
expect(ids).toEqual([
|
|
372
|
+
'questroom.title',
|
|
373
|
+
'promocode.discount',
|
|
374
|
+
'location.site',
|
|
375
|
+
'location.phone',
|
|
376
|
+
]);
|
|
377
|
+
});
|
|
378
|
+
it('оставляет unknown id как есть (не в карте)', () => {
|
|
379
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{xyzCustom}</p>');
|
|
380
|
+
const ids = ((result.doc.content?.[0]).content ||
|
|
381
|
+
[])
|
|
382
|
+
.filter((n) => n.attrs?.id)
|
|
383
|
+
.map((n) => n.attrs.id);
|
|
384
|
+
expect(ids).toEqual(['xyzCustom']);
|
|
385
|
+
});
|
|
386
|
+
it('оставляет block-payload id flat — у них нет namespaced версии', () => {
|
|
387
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Detail:</p><p>{orderDetailsHtml}</p>');
|
|
388
|
+
// Block-payload var оборачивается в htmlCodeBlock; id внутри —
|
|
389
|
+
// тот же flat (`orderDetailsHtml`), потому что block-payload'ы
|
|
390
|
+
// не имеют резолверов (значение собирается отдельным HTML-шагом).
|
|
391
|
+
const findVar = (nodes) => {
|
|
392
|
+
for (const n of nodes) {
|
|
393
|
+
if (n.type === 'variable')
|
|
394
|
+
return n.attrs;
|
|
395
|
+
const children = n.content;
|
|
396
|
+
if (children) {
|
|
397
|
+
const found = findVar(children);
|
|
398
|
+
if (found)
|
|
399
|
+
return found;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
};
|
|
404
|
+
expect(findVar(result.doc.content || [])?.id).toBe('orderDetailsHtml');
|
|
405
|
+
});
|
|
406
|
+
it('опция idMap=null отключает маппинг (для ad-hoc преобразований)', () => {
|
|
407
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{clientName}</p>', { idMap: null });
|
|
408
|
+
const ids = ((result.doc.content?.[0]).content ||
|
|
409
|
+
[])
|
|
410
|
+
.filter((n) => n.attrs?.id)
|
|
411
|
+
.map((n) => n.attrs.id);
|
|
412
|
+
expect(ids).toEqual(['clientName']);
|
|
413
|
+
});
|
|
414
|
+
it('кастомная idMap имеет приоритет над дефолтной', () => {
|
|
415
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{clientName}</p>', {
|
|
416
|
+
idMap: { clientName: 'custom.name' },
|
|
417
|
+
});
|
|
418
|
+
const ids = ((result.doc.content?.[0]).content ||
|
|
419
|
+
[])
|
|
420
|
+
.filter((n) => n.attrs?.id)
|
|
421
|
+
.map((n) => n.attrs.id);
|
|
422
|
+
expect(ids).toEqual(['custom.name']);
|
|
423
|
+
});
|
|
424
|
+
it('обновляет label при маппинге (берёт label по новому id)', () => {
|
|
425
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{clientName}</p>', {
|
|
426
|
+
labels: { 'client.firstName': 'Имя клиента' },
|
|
427
|
+
});
|
|
428
|
+
const v = ((result.doc.content?.[0]).content || []).find((n) => n.attrs?.id);
|
|
429
|
+
expect(v?.attrs?.label).toBe('Имя клиента');
|
|
430
|
+
});
|
|
431
|
+
it('переменная внутри section/if тоже маппится', () => {
|
|
432
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('{{#if discount}}<p>{discount}</p>{{/if}}');
|
|
433
|
+
const section = result.doc.content?.[0];
|
|
434
|
+
const inner = section.content[0].content.find((n) => n.attrs?.id);
|
|
435
|
+
expect(inner?.attrs?.id).toBe('promocode.discount');
|
|
436
|
+
});
|
|
437
|
+
it('LEGACY_TO_NAMESPACED_ID_MAP содержит все ключевые маппинги', () => {
|
|
438
|
+
// Защита от случайного удаления записей из карты — без них
|
|
439
|
+
// мигрированные шаблоны сломаются на v2-render.
|
|
440
|
+
expect(from_html_1.LEGACY_TO_NAMESPACED_ID_MAP.clientName).toBe('client.firstName');
|
|
441
|
+
expect(from_html_1.LEGACY_TO_NAMESPACED_ID_MAP.questroomTitle).toBe('questroom.title');
|
|
442
|
+
expect(from_html_1.LEGACY_TO_NAMESPACED_ID_MAP.promocode).toBe('promocode.code');
|
|
443
|
+
// `profileTitle` намеренно НЕ в карте — пользователь сам впишет
|
|
444
|
+
// название компании прямо в текст шаблона. См. `inlineValues`-
|
|
445
|
+
// ветку в `convertLegacyHtmlToEmailContent`.
|
|
446
|
+
expect(from_html_1.LEGACY_TO_NAMESPACED_ID_MAP.profileTitle).toBeUndefined();
|
|
447
|
+
expect(from_html_1.LEGACY_TO_NAMESPACED_ID_MAP.howToFindLocation).toBe('location.howToFind');
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
describe('findUnknownPlaceholders', () => {
|
|
451
|
+
it('возвращает пустой массив для известных плейсхолдеров', () => {
|
|
452
|
+
const html = '<p>{clientName} {questroomTitle} {{profileTitle}}</p>';
|
|
453
|
+
expect((0, from_html_1.findUnknownPlaceholders)(html)).toEqual([]);
|
|
454
|
+
});
|
|
455
|
+
it('находит неизвестный {firstName} (опечатка вместо clientName)', () => {
|
|
456
|
+
const html = '<p>{firstName}!</p>';
|
|
457
|
+
expect((0, from_html_1.findUnknownPlaceholders)(html)).toEqual(['firstName']);
|
|
458
|
+
});
|
|
459
|
+
it('игнорирует управляющие конструкции', () => {
|
|
460
|
+
const html = '<p>{{#if promocode}}{promocode}{{/if}}</p>';
|
|
461
|
+
expect((0, from_html_1.findUnknownPlaceholders)(html)).toEqual([]);
|
|
462
|
+
});
|
|
463
|
+
it('игнорирует namespaced (client.firstName) - предполагается что разработчик знает что делает', () => {
|
|
464
|
+
const html = '<p>{{client.firstName}}</p>';
|
|
465
|
+
expect((0, from_html_1.findUnknownPlaceholders)(html)).toEqual([]);
|
|
466
|
+
});
|
|
467
|
+
it('сортирует и дедуплицирует', () => {
|
|
468
|
+
const html = '<p>{xxx} {aaa} {xxx}</p>';
|
|
469
|
+
expect((0, from_html_1.findUnknownPlaceholders)(html)).toEqual(['aaa', 'xxx']);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
describe('snapshot: реальные шаблоны из crm-crosssales/ru.json', () => {
|
|
473
|
+
/*
|
|
474
|
+
* Кейсы подсмотрены в `packages/api/translations/ru/crm-crosssales.json`.
|
|
475
|
+
* Это **дефолтные тексты**, которыми проинициализированы шаблоны у
|
|
476
|
+
* новых профилей. Если конвертер не падает на них — высокая
|
|
477
|
+
* вероятность, что он справится и с пользовательскими модификациями
|
|
478
|
+
* (они изменяют тексты, но не структуру).
|
|
479
|
+
*/
|
|
480
|
+
it('crossSale.text — заголовок + {{#if}}-блоки + переменные', () => {
|
|
481
|
+
const html = '<p><strong>Здравствуйте, {clientName}!</strong></p>' +
|
|
482
|
+
'<p><br></p>' +
|
|
483
|
+
'<p>Спасибо, что посетили наш эскейп-рум "{questroomTitle}".</p>' +
|
|
484
|
+
'<p><br></p>' +
|
|
485
|
+
'<p>{{#if gameResult}}</p>' +
|
|
486
|
+
'<p>Поздравляем — <strong>{gameResult} мин</strong>!</p>' +
|
|
487
|
+
'<p>{{/if}}</p>' +
|
|
488
|
+
'<p>{{#if photos}}</p>' +
|
|
489
|
+
'<p>А вот фото: {photos}</p>' +
|
|
490
|
+
'<p>{{/if}}</p>' +
|
|
491
|
+
'<p>Сайт: {website}. Промокод: <strong>{promocode}</strong>, скидка <strong>{discount}</strong> до {validity}.</p>' +
|
|
492
|
+
'<p>С уважением,</p>' +
|
|
493
|
+
'<p>Команда {{profileTitle}}</p>';
|
|
494
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)(html);
|
|
495
|
+
const body = docBody(result);
|
|
496
|
+
// Структура должна содержать минимум: paragraph + 2 section'а + paragraph(финал)
|
|
497
|
+
const types = body.map((n) => n.type);
|
|
498
|
+
// Двумя section'ами обернуты gameResult и photos
|
|
499
|
+
const sections = types.filter((t) => t === 'section');
|
|
500
|
+
expect(sections.length).toBe(2);
|
|
501
|
+
/*
|
|
502
|
+
* `{photos}` теперь конвертится в `image`-ноду с
|
|
503
|
+
* `isSrcVariable: true, src: 'photos'` (а не в `htmlCodeBlock`,
|
|
504
|
+
* как раньше). Это даёт пользователю редактируемую картинку
|
|
505
|
+
* в Maily вместо raw-HTML галереи, см. комментарий в
|
|
506
|
+
* `from-html.ts → photoFirstImageNode`.
|
|
507
|
+
*/
|
|
508
|
+
const photosSection = body.find((n) => n.attrs?.showIfKey === 'photos');
|
|
509
|
+
const photosImage = photosSection.content.find((n) => n.type === 'image');
|
|
510
|
+
expect(photosImage).toBeDefined();
|
|
511
|
+
expect(photosImage?.attrs).toMatchObject({
|
|
512
|
+
src: 'photos',
|
|
513
|
+
isSrcVariable: true,
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
it('{photos} в legacy → image-нода с переменным src (photos)', () => {
|
|
517
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Фото команды: {photos}</p>');
|
|
518
|
+
const body = docBody(result);
|
|
519
|
+
const image = body.find((n) => n.type === 'image');
|
|
520
|
+
expect(image).toBeDefined();
|
|
521
|
+
expect(image.attrs).toMatchObject({
|
|
522
|
+
src: 'photos',
|
|
523
|
+
isSrcVariable: true,
|
|
524
|
+
alignment: 'center',
|
|
525
|
+
});
|
|
526
|
+
// htmlCodeBlock больше не должен порождаться для photos
|
|
527
|
+
const htmlBlock = body.find((n) => n.type === 'htmlCodeBlock');
|
|
528
|
+
expect(htmlBlock).toBeUndefined();
|
|
529
|
+
});
|
|
530
|
+
it('customClientBooking — kнопка бронирования + orderDetailsHtml', () => {
|
|
531
|
+
const html = '<p>{clientName}, благодарим за бронирование «{questroomTitle}»!</p>' +
|
|
532
|
+
'{orderDetailsHtml}' +
|
|
533
|
+
'<p>{{#manageBooking}}Управлять бронью{{/manageBooking}}</p>';
|
|
534
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)(html);
|
|
535
|
+
const body = docBody(result);
|
|
536
|
+
const findOfType = (nodes, type) => {
|
|
537
|
+
for (const n of nodes) {
|
|
538
|
+
if (n.type === type)
|
|
539
|
+
return n;
|
|
540
|
+
const children = n.content;
|
|
541
|
+
if (children) {
|
|
542
|
+
const found = findOfType(children, type);
|
|
543
|
+
if (found)
|
|
544
|
+
return found;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return null;
|
|
548
|
+
};
|
|
549
|
+
expect(findOfType(body, 'htmlCodeBlock')).toBeDefined();
|
|
550
|
+
expect(findOfType(body, 'button')).toBeDefined();
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
describe('migratePhotosToImage', () => {
|
|
554
|
+
const wrap = (nodes) => ({
|
|
555
|
+
version: email_builder_1.EMAIL_CONTENT_VERSION_V2,
|
|
556
|
+
doc: { type: 'doc', content: nodes },
|
|
557
|
+
theme: undefined,
|
|
558
|
+
});
|
|
559
|
+
it('заменяет top-level variable[id=photos] на image-ноду', () => {
|
|
560
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
561
|
+
{
|
|
562
|
+
type: 'variable',
|
|
563
|
+
attrs: { id: 'photos', label: 'Photos' },
|
|
564
|
+
},
|
|
565
|
+
]));
|
|
566
|
+
const body = (result.doc.content || []);
|
|
567
|
+
expect(body).toHaveLength(1);
|
|
568
|
+
expect(body[0].type).toBe('image');
|
|
569
|
+
expect(body[0].attrs).toMatchObject({
|
|
570
|
+
src: 'photos',
|
|
571
|
+
isSrcVariable: true,
|
|
572
|
+
width: 200,
|
|
573
|
+
height: 200,
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
it('заменяет paragraph с единственной variable[id=photos] на image-ноду', () => {
|
|
577
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
578
|
+
{
|
|
579
|
+
type: 'paragraph',
|
|
580
|
+
content: [{ type: 'variable', attrs: { id: 'photos' } }],
|
|
581
|
+
},
|
|
582
|
+
]));
|
|
583
|
+
const body = (result.doc.content || []);
|
|
584
|
+
expect(body[0].type).toBe('image');
|
|
585
|
+
});
|
|
586
|
+
it('заменяет variable[id=order.photoFirst] (legacy backward-compat) на image-ноду', () => {
|
|
587
|
+
// `order.photoFirst` — переходное имя, могло остаться у разработчиков
|
|
588
|
+
// в локальных redux-state'ах; нормализатор должен подменить его на
|
|
589
|
+
// image-узел с `src: 'photos'`, чтобы UI был консистентным.
|
|
590
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
591
|
+
{
|
|
592
|
+
type: 'paragraph',
|
|
593
|
+
content: [{ type: 'variable', attrs: { id: 'order.photoFirst' } }],
|
|
594
|
+
},
|
|
595
|
+
]));
|
|
596
|
+
const body = (result.doc.content || []);
|
|
597
|
+
expect(body[0].type).toBe('image');
|
|
598
|
+
expect(body[0].attrs).toMatchObject({ src: 'photos', isSrcVariable: true });
|
|
599
|
+
});
|
|
600
|
+
it('заменяет htmlCodeBlock-обёртку с photos-variable на image', () => {
|
|
601
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
602
|
+
{
|
|
603
|
+
type: 'htmlCodeBlock',
|
|
604
|
+
content: [{ type: 'variable', attrs: { id: 'photos' } }],
|
|
605
|
+
},
|
|
606
|
+
]));
|
|
607
|
+
const body = (result.doc.content || []);
|
|
608
|
+
expect(body[0].type).toBe('image');
|
|
609
|
+
});
|
|
610
|
+
it('идёт рекурсивно внутрь section и подменяет photos', () => {
|
|
611
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
612
|
+
{
|
|
613
|
+
type: 'section',
|
|
614
|
+
attrs: { showIfKey: 'cond' },
|
|
615
|
+
content: [
|
|
616
|
+
{
|
|
617
|
+
type: 'paragraph',
|
|
618
|
+
content: [{ type: 'variable', attrs: { id: 'photos' } }],
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
},
|
|
622
|
+
]));
|
|
623
|
+
const section = (result.doc.content || [])[0];
|
|
624
|
+
expect(section.content[0].type).toBe('image');
|
|
625
|
+
});
|
|
626
|
+
it('идемпотентность: повторный вызов ничего не меняет', () => {
|
|
627
|
+
const initial = (0, from_html_1.migratePhotosToImage)(wrap([{ type: 'variable', attrs: { id: 'photos' } }]));
|
|
628
|
+
const repeated = (0, from_html_1.migratePhotosToImage)(initial);
|
|
629
|
+
expect(repeated).toEqual(initial);
|
|
630
|
+
});
|
|
631
|
+
it('не трогает чужие variable (id != photos)', () => {
|
|
632
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
633
|
+
{
|
|
634
|
+
type: 'paragraph',
|
|
635
|
+
content: [{ type: 'variable', attrs: { id: 'client.firstName' } }],
|
|
636
|
+
},
|
|
637
|
+
]));
|
|
638
|
+
const body = (result.doc.content || [])[0];
|
|
639
|
+
expect(body.type).toBe('paragraph');
|
|
640
|
+
});
|
|
641
|
+
it('не трогает параграф со смешанным контентом (photos + текст)', () => {
|
|
642
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
643
|
+
{
|
|
644
|
+
type: 'paragraph',
|
|
645
|
+
content: [
|
|
646
|
+
{ type: 'text', text: 'Фото: ' },
|
|
647
|
+
{ type: 'variable', attrs: { id: 'photos' } },
|
|
648
|
+
],
|
|
649
|
+
},
|
|
650
|
+
]));
|
|
651
|
+
const body = (result.doc.content || [])[0];
|
|
652
|
+
expect(body.type).toBe('paragraph');
|
|
653
|
+
});
|
|
654
|
+
it('удаляет variable[id=servicesList] из doc (функция выпилена)', () => {
|
|
655
|
+
// `servicesList` больше не разворачивается в `repeat`-ноду —
|
|
656
|
+
// фича up-sale-листа в письмах выпилена. Нормализатор удаляет
|
|
657
|
+
// chip из doc'а: parent-параграф, содержащий ТОЛЬКО эту переменную,
|
|
658
|
+
// тоже отбрасывается (см. `migratePhotosInNodes` → ветка (2)).
|
|
659
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
660
|
+
{
|
|
661
|
+
type: 'paragraph',
|
|
662
|
+
content: [{ type: 'variable', attrs: { id: 'servicesList' } }],
|
|
663
|
+
},
|
|
664
|
+
]));
|
|
665
|
+
const body = (result.doc.content || []);
|
|
666
|
+
expect(body.some((n) => n.type === 'repeat')).toBe(false);
|
|
667
|
+
// Параграф-обёртка тоже схлопывается, чтобы не оставлять
|
|
668
|
+
// мёртвый пустой блок (`meaningful.length === 1 → factory []`).
|
|
669
|
+
expect(body).toHaveLength(0);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
describe('convertLegacyHtmlToEmailContent — {servicesList} (выпилен)', () => {
|
|
673
|
+
/*
|
|
674
|
+
* Функция up-sale-листа выпилена из email-системы. legacy токен
|
|
675
|
+
* `{servicesList}` всё ещё может прийти из старых шаблонов на проде —
|
|
676
|
+
* проверяем, что конвертер молча его проглатывает (не оставляет
|
|
677
|
+
* литеральным текстом, не порождает repeat-ноду, не порождает
|
|
678
|
+
* htmlCodeBlock).
|
|
679
|
+
*/
|
|
680
|
+
const wrap = (nodes) => ({
|
|
681
|
+
version: email_builder_1.EMAIL_CONTENT_VERSION_V2,
|
|
682
|
+
doc: { type: 'doc', content: nodes },
|
|
683
|
+
theme: undefined,
|
|
684
|
+
});
|
|
685
|
+
it('legacy {servicesList} в обёртке `<p>` схлопывается полностью', () => {
|
|
686
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{servicesList}</p>');
|
|
687
|
+
const body = docBody(result);
|
|
688
|
+
expect(body.some((n) => n.type === 'repeat')).toBe(false);
|
|
689
|
+
expect(body.some((n) => n.type === 'htmlCodeBlock')).toBe(false);
|
|
690
|
+
// Параграф-обёртка с единственной servicesList-переменной
|
|
691
|
+
// выкидывается нормализатором без замены.
|
|
692
|
+
expect(body).toHaveLength(0);
|
|
693
|
+
});
|
|
694
|
+
it('inline {servicesList} вырезается, остальной текст сохраняется', () => {
|
|
695
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>Доп. услуги: {servicesList} спасибо</p>');
|
|
696
|
+
const body = docBody(result);
|
|
697
|
+
// Параграф остался, так как есть текст вокруг переменной;
|
|
698
|
+
// сама `variable[id=servicesList]` внутри не должна доехать
|
|
699
|
+
// до конвертированного результата.
|
|
700
|
+
expect(body).toHaveLength(1);
|
|
701
|
+
expect(body[0].type).toBe('paragraph');
|
|
702
|
+
const allVarIds = (body[0].content || [])
|
|
703
|
+
.filter((c) => c.type === 'variable')
|
|
704
|
+
.map((c) => c.attrs?.id);
|
|
705
|
+
expect(allVarIds).not.toContain('servicesList');
|
|
706
|
+
});
|
|
707
|
+
it('migratePhotosToImage идемпотентен для servicesList (повторный прогон ничего не меняет)', () => {
|
|
708
|
+
// После первого прогона variable-чип уже удалён, повторный
|
|
709
|
+
// прогон видит пустой doc и тоже его не трогает. Это важно
|
|
710
|
+
// для refill-сценария: рефилл может прогнать миграцию повторно,
|
|
711
|
+
// и она не должна породить никаких новых нод.
|
|
712
|
+
const once = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>{servicesList}</p>');
|
|
713
|
+
const twice = (0, from_html_1.migratePhotosToImage)(once);
|
|
714
|
+
const body = (twice.doc.content || []);
|
|
715
|
+
expect(body).toHaveLength(0);
|
|
716
|
+
});
|
|
717
|
+
it('{{#if servicesList}}…{{/if}} вырезается целиком вместе с заголовком внутри', () => {
|
|
718
|
+
// Сценарий рефила: в legacy шаблоне жил блок-приглашение
|
|
719
|
+
// докупить услуги. Сам chip удаляется factory `() => []`, но
|
|
720
|
+
// section-обёртка с заголовком («Сделайте игру ярче…») при
|
|
721
|
+
// этом оставалась пустым box'ом — пользователь видел её в
|
|
722
|
+
// редакторе. Теперь secition тоже схлопывается.
|
|
723
|
+
const result = (0, from_html_1.convertLegacyHtmlToEmailContent)('<p>До игры:</p>{{#if servicesList}}<p><strong>Сделайте игру ярче</strong></p><p>{servicesList}</p>{{/if}}<p>До встречи!</p>');
|
|
724
|
+
const body = docBody(result);
|
|
725
|
+
// Никаких section'ов с showIfKey=servicesList не осталось.
|
|
726
|
+
expect(body.some((n) => n.type === 'section' && n.attrs?.showIfKey === 'servicesList')).toBe(false);
|
|
727
|
+
// Соседние параграфы («До игры:» и «До встречи!») сохранились.
|
|
728
|
+
expect(body.filter((n) => n.type === 'paragraph')).toHaveLength(2);
|
|
729
|
+
});
|
|
730
|
+
it('migratePhotosToImage чистит legacy `section[showIfKey=servicesList]` из сохранённого doc', () => {
|
|
731
|
+
// Прямая проверка нормализатора для случая, когда doc уже
|
|
732
|
+
// лежит в БД как `section[showIfKey='servicesList']` (на проде
|
|
733
|
+
// такие шаблоны живут массово). Симметрично factory `() => []`
|
|
734
|
+
// для самой переменной — обёртка тоже должна исчезнуть.
|
|
735
|
+
const result = (0, from_html_1.migratePhotosToImage)(wrap([
|
|
736
|
+
{
|
|
737
|
+
type: 'section',
|
|
738
|
+
attrs: { showIfKey: 'servicesList' },
|
|
739
|
+
content: [
|
|
740
|
+
{
|
|
741
|
+
type: 'paragraph',
|
|
742
|
+
content: [{ type: 'text', text: 'Сделайте игру ярче' }],
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
},
|
|
746
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'После' }] },
|
|
747
|
+
]));
|
|
748
|
+
const body = (result.doc.content || []);
|
|
749
|
+
expect(body.some((n) => n.type === 'section')).toBe(false);
|
|
750
|
+
expect(body).toHaveLength(1);
|
|
751
|
+
expect(body[0].type).toBe('paragraph');
|
|
752
|
+
});
|
|
753
|
+
});
|