backend-manager 5.10.2 → 5.11.0
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/CLAUDE.md +3 -1
- package/docs/admin-post-route.md +8 -5
- package/docs/cdp-debugging.md +15 -30
- package/docs/ghostii.md +18 -11
- package/docs/marketing-campaigns.md +11 -7
- package/package.json +1 -1
- package/src/cli/index.js +7 -1
- package/src/defaults/CLAUDE.md +2 -0
- package/src/manager/events/cron/daily/blog-auto-publisher.js +61 -150
- package/src/manager/libraries/content/source-resolver.js +296 -109
- package/src/manager/libraries/email/generators/newsletter.js +147 -69
- package/src/manager/routes/admin/post/post.js +69 -1
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
- package/src/test/fixtures/firebase-project/database-debug.log +8 -8
- package/src/test/fixtures/firebase-project/firestore-debug.log +55 -57
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/templates/backend-manager-config.json +1 -0
- package/test/email/newsletter-generate.js +4 -5
- package/test/helpers/content/blog-auto-publisher.js +116 -132
- package/test/helpers/content/ghostii-feed-integration.js +2 -2
- package/test/routes/admin/post-convert-image.js +158 -0
- package/test/routes/admin/post-download-error.js +89 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: routes/admin/post/post.convertToJpeg + applyImageCDNParams
|
|
3
|
+
* Unit tests for non-JPG ingest conversion and CDN pre-scale params used by
|
|
4
|
+
* the admin/post route.
|
|
5
|
+
*
|
|
6
|
+
* Run (from the framework repo): npm test routes/admin/post-convert-image
|
|
7
|
+
*
|
|
8
|
+
* Contract:
|
|
9
|
+
* - png/webp downloads are converted in place to progressive JPEG at
|
|
10
|
+
* IMAGE_JPEG_QUALITY; the result's path/filename/ext all become .jpg and
|
|
11
|
+
* the original file is removed.
|
|
12
|
+
* - Alpha channels are flattened onto white (JPEG has no transparency).
|
|
13
|
+
* - applyImageCDNParams adds w/q params for Unsplash and w/auto params for
|
|
14
|
+
* Pexels (without clobbering params already present), and leaves other
|
|
15
|
+
* hosts untouched.
|
|
16
|
+
*/
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const jetpack = require('fs-jetpack');
|
|
20
|
+
const sharp = require('sharp');
|
|
21
|
+
|
|
22
|
+
const post = require('../../../src/manager/routes/admin/post/post');
|
|
23
|
+
|
|
24
|
+
const { convertToJpeg, applyImageCDNParams, IMAGE_MAX_DIMENSION } = post;
|
|
25
|
+
|
|
26
|
+
// Generate a synthetic image of the given format and write it to a tmp path.
|
|
27
|
+
async function makeImage(format, options) {
|
|
28
|
+
const filepath = path.join(os.tmpdir(), `bem-test-convert-${Date.now()}-${Math.random().toString(36).slice(2)}.${format}`);
|
|
29
|
+
const buffer = await sharp({
|
|
30
|
+
create: {
|
|
31
|
+
width: 640,
|
|
32
|
+
height: 480,
|
|
33
|
+
channels: options?.alpha ? 4 : 3,
|
|
34
|
+
background: options?.alpha
|
|
35
|
+
? { r: 200, g: 50, b: 50, alpha: 0.5 }
|
|
36
|
+
: { r: 200, g: 50, b: 50 },
|
|
37
|
+
},
|
|
38
|
+
})[format]()
|
|
39
|
+
.toBuffer();
|
|
40
|
+
|
|
41
|
+
jetpack.write(filepath, buffer);
|
|
42
|
+
return filepath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Minimal assistant stub — convertToJpeg only uses Manager.require + log.
|
|
46
|
+
function makeAssistant() {
|
|
47
|
+
return {
|
|
48
|
+
log: () => {},
|
|
49
|
+
Manager: {
|
|
50
|
+
require: (mod) => require(mod),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
description: 'routes/admin/post/post.convertToJpeg + applyImageCDNParams',
|
|
57
|
+
type: 'group',
|
|
58
|
+
|
|
59
|
+
tests: [
|
|
60
|
+
// ─── convertToJpeg ───
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
name: 'png-converts-to-jpg',
|
|
64
|
+
async run({ assert }) {
|
|
65
|
+
const filepath = await makeImage('png');
|
|
66
|
+
const result = { path: filepath, filename: path.basename(filepath), ext: '.png' };
|
|
67
|
+
|
|
68
|
+
await convertToJpeg(makeAssistant(), result);
|
|
69
|
+
|
|
70
|
+
assert.equal(result.ext, '.jpg', 'ext should become .jpg');
|
|
71
|
+
assert.ok(result.path.endsWith('.jpg'), 'path should end with .jpg');
|
|
72
|
+
assert.ok(result.filename.endsWith('.jpg'), 'filename should end with .jpg');
|
|
73
|
+
assert.ok(jetpack.exists(result.path), 'converted file should exist');
|
|
74
|
+
assert.equal(jetpack.exists(filepath), false, 'original .png should be removed');
|
|
75
|
+
|
|
76
|
+
const meta = await sharp(result.path).metadata();
|
|
77
|
+
assert.equal(meta.format, 'jpeg', 'on-disk format should be JPEG');
|
|
78
|
+
assert.equal(meta.width, 640, 'dimensions should be preserved');
|
|
79
|
+
|
|
80
|
+
jetpack.remove(result.path);
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
name: 'webp-converts-to-jpg',
|
|
86
|
+
async run({ assert }) {
|
|
87
|
+
const filepath = await makeImage('webp');
|
|
88
|
+
const result = { path: filepath, filename: path.basename(filepath), ext: '.webp' };
|
|
89
|
+
|
|
90
|
+
await convertToJpeg(makeAssistant(), result);
|
|
91
|
+
|
|
92
|
+
assert.equal(result.ext, '.jpg', 'ext should become .jpg');
|
|
93
|
+
const meta = await sharp(result.path).metadata();
|
|
94
|
+
assert.equal(meta.format, 'jpeg', 'on-disk format should be JPEG');
|
|
95
|
+
assert.equal(jetpack.exists(filepath), false, 'original .webp should be removed');
|
|
96
|
+
|
|
97
|
+
jetpack.remove(result.path);
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
name: 'alpha-png-flattens-onto-white',
|
|
103
|
+
async run({ assert }) {
|
|
104
|
+
const filepath = await makeImage('png', { alpha: true });
|
|
105
|
+
const result = { path: filepath, filename: path.basename(filepath), ext: '.png' };
|
|
106
|
+
|
|
107
|
+
await convertToJpeg(makeAssistant(), result);
|
|
108
|
+
|
|
109
|
+
const meta = await sharp(result.path).metadata();
|
|
110
|
+
assert.equal(meta.format, 'jpeg', 'on-disk format should be JPEG');
|
|
111
|
+
assert.equal(meta.channels, 3, 'alpha channel should be gone');
|
|
112
|
+
|
|
113
|
+
// 50%-alpha red over white ≈ light red — the green/blue channels must be
|
|
114
|
+
// well above 0 (a black flatten would leave them near 25).
|
|
115
|
+
const { channels } = await sharp(result.path).stats();
|
|
116
|
+
assert.ok(channels[1].mean > 100, `green mean should reflect a white flatten (got ${Math.round(channels[1].mean)})`);
|
|
117
|
+
|
|
118
|
+
jetpack.remove(result.path);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// ─── applyImageCDNParams ───
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
name: 'unsplash-gets-w-and-q-params',
|
|
126
|
+
async run({ assert }) {
|
|
127
|
+
const out = new URL(applyImageCDNParams('https://images.unsplash.com/photo-123'));
|
|
128
|
+
assert.equal(out.searchParams.get('w'), String(IMAGE_MAX_DIMENSION), 'w param should be the max dimension');
|
|
129
|
+
assert.ok(out.searchParams.get('q'), 'q param should be set');
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
name: 'pexels-gets-w-and-auto-params',
|
|
135
|
+
async run({ assert }) {
|
|
136
|
+
const out = new URL(applyImageCDNParams('https://images.pexels.com/photos/416405/pexels-photo-416405.jpeg'));
|
|
137
|
+
assert.equal(out.searchParams.get('w'), String(IMAGE_MAX_DIMENSION), 'w param should be the max dimension');
|
|
138
|
+
assert.equal(out.searchParams.get('auto'), 'compress', 'auto=compress should be set');
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
name: 'existing-params-are-not-clobbered',
|
|
144
|
+
async run({ assert }) {
|
|
145
|
+
const out = new URL(applyImageCDNParams('https://images.pexels.com/photos/1/a.jpeg?w=800'));
|
|
146
|
+
assert.equal(out.searchParams.get('w'), '800', 'an existing w param should be preserved');
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
name: 'other-hosts-untouched',
|
|
152
|
+
async run({ assert }) {
|
|
153
|
+
const src = 'https://cdn.pixabay.com/photo/2024/01/01/example.jpg';
|
|
154
|
+
assert.equal(applyImageCDNParams(src), src, 'hosts without CDN param support should pass through unchanged');
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: routes/admin/post/post.formatImageDownloadError
|
|
3
|
+
* Unit tests for the readable image-download error messages used by the
|
|
4
|
+
* admin/post route.
|
|
5
|
+
*
|
|
6
|
+
* Run (from the framework repo): npm test routes/admin/post-download-error
|
|
7
|
+
*
|
|
8
|
+
* Contract:
|
|
9
|
+
* - Raw HTML error bodies (CDN 404 pages) are stripped to their text, so
|
|
10
|
+
* consumers surface "Could not download image (<url>): 404" instead of
|
|
11
|
+
* markup that downstream HTML rendering swallows.
|
|
12
|
+
* - The failing image URL is always included so the caller knows WHICH
|
|
13
|
+
* image broke.
|
|
14
|
+
* - Long reasons are truncated; empty reasons fall back to "unknown error".
|
|
15
|
+
*/
|
|
16
|
+
const post = require('../../../src/manager/routes/admin/post/post');
|
|
17
|
+
|
|
18
|
+
const { formatImageDownloadError } = post;
|
|
19
|
+
|
|
20
|
+
const SRC = 'https://images.unsplash.com/photos/ux-prism-qv5lQ4DwOS8';
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
description: 'routes/admin/post/post.formatImageDownloadError',
|
|
24
|
+
type: 'group',
|
|
25
|
+
|
|
26
|
+
tests: [
|
|
27
|
+
{
|
|
28
|
+
name: 'strips-html-404-body',
|
|
29
|
+
async run({ assert }) {
|
|
30
|
+
// The exact CDN response body from a real failed sponsorship publish
|
|
31
|
+
const err = new Error('<html><body>404</body></html>');
|
|
32
|
+
|
|
33
|
+
assert.equal(
|
|
34
|
+
formatImageDownloadError(SRC, err),
|
|
35
|
+
`Could not download image (${SRC}): 404`,
|
|
36
|
+
'HTML tags are stripped, the status text and image URL survive',
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
name: 'plain-message-passes-through',
|
|
43
|
+
async run({ assert }) {
|
|
44
|
+
assert.equal(
|
|
45
|
+
formatImageDownloadError(SRC, new Error('socket hang up')),
|
|
46
|
+
`Could not download image (${SRC}): socket hang up`,
|
|
47
|
+
'Plain reasons are included as-is',
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
name: 'truncates-long-reasons',
|
|
54
|
+
async run({ assert }) {
|
|
55
|
+
const result = formatImageDownloadError(SRC, new Error('x'.repeat(500)));
|
|
56
|
+
|
|
57
|
+
assert.ok(result.endsWith('...'), 'Truncated reasons end with an ellipsis');
|
|
58
|
+
assert.ok(result.length < 300, 'Reason is capped well below the raw length');
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
name: 'empty-reason-falls-back',
|
|
64
|
+
async run({ assert }) {
|
|
65
|
+
assert.equal(
|
|
66
|
+
formatImageDownloadError(SRC, new Error('<html></html>')),
|
|
67
|
+
`Could not download image (${SRC}): unknown error`,
|
|
68
|
+
'Tag-only bodies fall back to a generic reason',
|
|
69
|
+
);
|
|
70
|
+
assert.equal(
|
|
71
|
+
formatImageDownloadError(SRC, new Error('')),
|
|
72
|
+
`Could not download image (${SRC}): unknown error`,
|
|
73
|
+
'Empty messages fall back to a generic reason (not the stringified "Error")',
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
name: 'handles-non-error-values',
|
|
80
|
+
async run({ assert }) {
|
|
81
|
+
assert.equal(
|
|
82
|
+
formatImageDownloadError(SRC, 'ECONNRESET'),
|
|
83
|
+
`Could not download image (${SRC}): ECONNRESET`,
|
|
84
|
+
'Plain string rejections are handled',
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|