backend-manager 5.2.8 → 5.2.10

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/CHANGELOG.md CHANGED
@@ -14,6 +14,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.10] - 2026-05-26
18
+
19
+ ### Added
20
+
21
+ - **`POST /admin/post`: resize images at ingest.** Downloaded post images are now checked against `IMAGE_MAX_DIMENSION` (4096px on the long edge) in `src/manager/routes/admin/post/post.js` and re-encoded as progressive JPEG at `IMAGE_JPEG_QUALITY` (80) when oversized. Prevents downstream Jekyll/imagemin pipelines from stalling on huge sources (a real 16384×10576 source decoded to ~520MB raw and silently broke 4 StudyMonkey posts on production). `resizeImage`, `IMAGE_MAX_DIMENSION`, and `IMAGE_JPEG_QUALITY` are exported for tests. Adds `sharp` as a dependency.
22
+ - **`test/routes/admin/post-resize-image.js`** — 7 unit tests covering the resize contract (pass-through under the limit, boundary at exact limit, landscape/portrait/square scaling, the 16384×10576 case, on-disk overwrite). No network, no auth, no GitHub.
23
+ - **`test/routes/admin/create-post.js`**: extended `create-post-rewrites-body-images` to submit a 5000×3000 header image, plus new `verify-oversized-header-image-was-resized` step that fetches the committed image back from GitHub and asserts long edge ≤ 4096px.
24
+
25
+ ### Changed
26
+
27
+ - **Dependency bumps**: `sharp` ^0.34.4 → ^0.34.5, `sanitize-html` ^2.17.3 → ^2.17.4 (auto-bumped at install).
28
+
29
+ # [5.2.9] - 2026-05-25
30
+
31
+ ### Added
32
+
33
+ - **OpenAI provider: multi-role prompt array support.** `options.prompt` now accepts either the legacy object form (`{ path|content, settings }`, auto-wrapped as a single `system`-role segment per the OpenAI Model Spec) OR an array form (`[{ role, path|content, settings }, ...]`) where each segment becomes its own message with its declared role. Valid roles: `system`, `developer`, `user`, `assistant`; order is preserved; role defaults to `system` if omitted; invalid roles throw. New internal helpers `normalizePrompt()` + `VALID_PROMPT_ROLES` canonicalize input; the request pipeline threads `promptSegments` end-to-end (per-segment load, per-role logging, per-segment error surfacing, `formatHistory` unshifts segments in declared order). `module.exports._internals` exposes `normalizePrompt`, `formatHistory`, `VALID_PROMPT_ROLES` for unit tests — not part of the public API. Backwards compatible: existing callers passing `prompt: { content: '...' }` are auto-wrapped as a single `system` segment with no consumer changes.
34
+ - **`test/helpers/ai-request-payload.js`** — 16 standalone tests covering the BEM → OpenAI payload transformation with no network and no assistant. Exercises `normalizePrompt` (undefined/null/empty handling, legacy object → single system segment, array-form role preservation, role-defaulting, invalid-role throw, full OpenAI Model Spec role coverage) and `formatHistory` (single-system emit, multi-segment order, empty-array → user-only, prompt+history+new-user interleaving, assistant `output_text` typing, history limit, `dedupeConsecutiveRoles` trailing-user drop, content trim/strip).
35
+
36
+ ### Changed
37
+
38
+ - **Default OpenAI model bumped to `gpt-5.4-mini`** (was `gpt-5-mini`). Updated in `src/manager/libraries/ai/providers/openai.js` (`DEFAULT_MODEL`), `src/manager/libraries/ai/index.js` (usage example in JSDoc), and `src/manager/libraries/infer-contact.js` (`inferContactWithAI`). The pricing table in the OpenAI provider already includes `gpt-5.4-mini`, so no further config changes are required.
39
+ - **`inferContactWithAI` maxTokens doubled to 2048** (was 1024). Gives the model headroom for the richer multi-field contact response without truncation.
40
+
17
41
  # [5.2.8] - 2026-05-25
18
42
 
19
43
  ### Changed
package/CLAUDE.md CHANGED
@@ -119,7 +119,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
119
119
 
120
120
  ### Built-in Routes
121
121
 
122
- - [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + `@post/` rewriting)
122
+ - [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + resize at ingest + `@post/` rewriting)
123
123
  - [docs/payment-system.md](docs/payment-system.md) — full payment pipeline: Intent → Webhook → On-Write → Transition; subscription model, statuses, `resolveSubscription()`, transition handlers, processor interface, product config, test processor
124
124
  - [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), template-owned schemas, asset hosting, seed campaigns
125
125
  - [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), migration script template
@@ -1,24 +1,46 @@
1
1
  # Admin Post Route
2
2
 
3
- The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, upload, and body rewriting.
3
+ The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, resize, upload, and body rewriting.
4
4
 
5
5
  ## Image Processing Flow
6
6
 
7
7
  1. Receives markdown body with external image URLs (e.g., `![alt](https://images.unsplash.com/...)`)
8
8
  2. Extracts all `![alt](url)` patterns from the body using regex
9
- 3. Downloads each image and uploads it to `src/assets/images/blog/post-{id}/` on GitHub
10
- 4. **Rewrites the body** to replace external URLs with `@post/{filename}` format
11
- 5. The `@post/` prefix is resolved at Jekyll build time by `jekyll-uj-powertools` to the full path
9
+ 3. Downloads each image to a tmp dir
10
+ 4. **Resizes** each image in place if its long edge exceeds `IMAGE_MAX_DIMENSION` (see below)
11
+ 5. Commits all images to `src/assets/images/blog/post-{id}/` on GitHub (single commit via Git Trees API)
12
+ 6. **Rewrites the body** to replace external URLs with `@post/{filename}` format
13
+ 7. The `@post/` prefix is resolved at Jekyll build time by `jekyll-uj-powertools` to the full path
14
+
15
+ ## Image resize
16
+
17
+ Sources from guest-post submissions can be enormous (16384×10576 has been seen in the wild — ~520MB raw RGB). Sources that large stall downstream Jekyll/imagemin pipelines on the consumer site, which silently ship the site missing those images.
18
+
19
+ To prevent this, every downloaded image is checked against `IMAGE_MAX_DIMENSION` (4096px on the long edge) and re-encoded as a progressive JPEG at `IMAGE_JPEG_QUALITY` (80) if it exceeds the limit. Images already at or below the limit pass through untouched.
20
+
21
+ The resize happens in `downloadImage()` (after the `.jpg` extension check, before returning to the caller), so:
22
+ - The base64 content that gets committed to GitHub is the resized version
23
+ - The consumer repo never sees the giant source
24
+ - Future downstream optimization (UJM imagemin, etc.) starts from a sane source size
25
+
26
+ Constants live in `src/manager/routes/admin/post/post.js`:
27
+ ```js
28
+ const IMAGE_MAX_DIMENSION = 4096;
29
+ const IMAGE_JPEG_QUALITY = 80;
30
+ ```
31
+
32
+ If these need to become configurable later, promote to `backend-manager-config.json` rather than env vars (deploy-environment-specific overrides aren't a real use case — the values are algorithm constants).
12
33
 
13
34
  ## Key Details
14
35
 
15
36
  - Image filenames are derived from `hyphenate(alt_text)` + downloaded extension
37
+ - Only `.jpg` is accepted; other formats reject with a 400
16
38
  - Header image (`headerImageURL`) is uploaded but NOT rewritten in the body (it's in frontmatter)
17
39
  - Failed image downloads are skipped — the original external URL stays in the body
18
- - The `extractImages()` function returns a URL mapping used for body rewriting
40
+ - All images + the post markdown are committed in a single commit via the Git Trees API
19
41
 
20
42
  ## Files
21
43
 
22
- - `src/manager/routes/admin/post/post.js` — POST handler (create)
23
- - `src/manager/routes/admin/post/put.js` — PUT handler (edit)
44
+ - `src/manager/routes/admin/post/post.js` — POST handler (create), includes `downloadImage()` + `resizeImage()` helpers
45
+ - `src/manager/routes/admin/post/put.js` — PUT handler (edit) — does NOT download images, just edits frontmatter/body in place
24
46
  - `src/manager/routes/admin/post/templates/post.html` — Post template
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.8",
3
+ "version": "5.2.10",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -49,18 +49,18 @@
49
49
  }
50
50
  },
51
51
  "dependencies": {
52
- "@anthropic-ai/claude-agent-sdk": "^0.2.140",
53
- "@anthropic-ai/sdk": "^0.95.2",
52
+ "@anthropic-ai/claude-agent-sdk": "^0.3.152",
53
+ "@anthropic-ai/sdk": "^0.99.0",
54
54
  "@firebase/rules-unit-testing": "^5.0.1",
55
55
  "@google-cloud/firestore": "^7.11.6",
56
56
  "@google-cloud/pubsub": "^5.3.0",
57
57
  "@google-cloud/storage": "^7.19.0",
58
- "@inquirer/prompts": "^8.4.3",
58
+ "@inquirer/prompts": "^8.5.0",
59
59
  "@modelcontextprotocol/sdk": "^1.29.0",
60
60
  "@octokit/rest": "^22.0.1",
61
61
  "@resvg/resvg-js": "^2.6.2",
62
62
  "@sendgrid/mail": "^8.1.6",
63
- "@sentry/node": "^10.53.1",
63
+ "@sentry/node": "^10.54.0",
64
64
  "body-parser": "^2.2.2",
65
65
  "busboy": "^1.6.0",
66
66
  "chalk": "^5.6.2",
@@ -76,15 +76,16 @@
76
76
  "lodash": "^4.18.1",
77
77
  "lowdb": "^7.0.1",
78
78
  "mailchimp-api-v3": "^1.15.0",
79
- "markdown-it": "^14.1.1",
79
+ "markdown-it": "^14.2.0",
80
80
  "mime-types": "^3.0.2",
81
- "mjml": "^5.2.1",
81
+ "mjml": "^5.3.0",
82
82
  "moment": "^2.30.1",
83
83
  "nanoid": "^5.1.11",
84
84
  "node-powertools": "^3.0.0",
85
85
  "npm-api": "^1.0.1",
86
86
  "pushid": "^1.0.0",
87
- "sanitize-html": "^2.17.3",
87
+ "sanitize-html": "^2.17.4",
88
+ "sharp": "^0.34.5",
88
89
  "stripe": "^22.1.1",
89
90
  "uid-generator": "^2.0.0",
90
91
  "uuid": "^14.0.0",
@@ -98,7 +99,7 @@
98
99
  "prepare-package": "^2.1.0"
99
100
  },
100
101
  "peerDependencies": {
101
- "firebase-admin": "^13.9.0",
102
+ "firebase-admin": "^13.10.0",
102
103
  "firebase-functions": "^7.2.5"
103
104
  }
104
105
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Usage:
5
5
  * const ai = Manager.AI(assistant);
6
- * const result = await ai.request({ provider: 'openai', model: 'gpt-5-mini', ... });
6
+ * const result = await ai.request({ provider: 'openai', model: 'gpt-5.4-mini', ... });
7
7
  * const result = await ai.request({ provider: 'anthropic', model: 'claude-sonnet-4-6', ... });
8
8
  *
9
9
  * Each provider returns { content, output, tokens, raw } with a consistent shape.
@@ -7,7 +7,7 @@ const path = require('path');
7
7
  const mimeTypes = require('mime-types');
8
8
 
9
9
  // Constants
10
- const DEFAULT_MODEL = 'gpt-5-mini';
10
+ const DEFAULT_MODEL = 'gpt-5.4-mini';
11
11
  const MODERATION_MODEL = 'omni-moderation-latest';
12
12
 
13
13
  // OpenAI model pricing table (per 1M tokens)
@@ -396,10 +396,25 @@ OpenAI.prototype.request = function (options) {
396
396
  options.reasoning = options.reasoning || undefined;
397
397
 
398
398
  // Format prompt
399
- options.prompt = options.prompt || {};
400
- options.prompt.path = options.prompt.path || '';
401
- options.prompt.content = options.prompt.content || options.prompt.content || '';
402
- options.prompt.settings = options.prompt.settings || {};
399
+ //
400
+ // Accepts two forms:
401
+ //
402
+ // 1) Object form (legacy / single-role):
403
+ // prompt: { path|content, settings }
404
+ // Auto-wrapped to a single 'system'-role segment per the OpenAI Model
405
+ // Spec — unlabeled prompts represent the platform's authoritative
406
+ // instruction.
407
+ //
408
+ // 2) Array form (multi-role):
409
+ // prompt: [
410
+ // { role: 'system', path|content, settings },
411
+ // { role: 'developer', path|content, settings },
412
+ // ...
413
+ // ]
414
+ // Each segment becomes its own message with the declared role. Order
415
+ // is preserved. Valid roles: 'system', 'developer', 'user',
416
+ // 'assistant'. Segments default to 'system' if role is omitted.
417
+ options.prompt = normalizePrompt(options.prompt);
403
418
 
404
419
  // Format message
405
420
  options.message = options.message || {};
@@ -428,19 +443,26 @@ OpenAI.prototype.request = function (options) {
428
443
  _log('Starting', options);
429
444
 
430
445
 
431
- // Load prompt
432
- const prompt = loadContent(options.prompt, _log);
446
+ // Load prompt segments (one entry per role) and the user message
447
+ const promptSegments = options.prompt.map((segment) => ({
448
+ role: segment.role,
449
+ content: loadContent(segment, _log),
450
+ }));
433
451
  const message = loadContent(options.message, _log);
434
452
  const user = options.user?.auth?.uid || assistant.request.geolocation.ip || 'unknown';
435
453
 
436
454
  // Log
437
- _log('Prompt', prompt);
455
+ for (const segment of promptSegments) {
456
+ _log(`Prompt[${segment.role}]`, segment.content);
457
+ }
438
458
  _log('Message', message);
439
459
  _log('User', user);
440
460
 
441
461
  // Check for errors
442
- if (prompt instanceof Error) {
443
- return reject(assistant.errorify(`Error loading prompt: ${prompt}`, {code: 400}));
462
+ for (const segment of promptSegments) {
463
+ if (segment.content instanceof Error) {
464
+ return reject(assistant.errorify(`Error loading prompt[${segment.role}]: ${segment.content}`, {code: 400}));
465
+ }
444
466
  }
445
467
 
446
468
  if (message instanceof Error) {
@@ -450,7 +472,7 @@ OpenAI.prototype.request = function (options) {
450
472
  // Moderate if needed
451
473
  let moderation = null;
452
474
  if (options.moderate) {
453
- moderation = await makeRequest('moderations', options, self, prompt, message, user, _log)
475
+ moderation = await makeRequest('moderations', options, self, promptSegments, message, user, _log)
454
476
  .then(async (r) => {
455
477
  // {
456
478
  // id: 'modr-8205',
@@ -481,7 +503,7 @@ OpenAI.prototype.request = function (options) {
481
503
 
482
504
 
483
505
  // Make attempt
484
- attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log);
506
+ attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
485
507
  });
486
508
  }
487
509
 
@@ -493,6 +515,39 @@ function tryParse(content) {
493
515
  }
494
516
  }
495
517
 
518
+ // Roles permitted in the `options.prompt` array. Order is canonical per the
519
+ // OpenAI Model Spec authority hierarchy (system > developer > user > assistant).
520
+ const VALID_PROMPT_ROLES = new Set(['system', 'developer', 'user', 'assistant']);
521
+
522
+ // Normalize the `options.prompt` input into a canonical array of segments:
523
+ // [{ role, path, content, settings }, ...]
524
+ //
525
+ // Accepts:
526
+ // - undefined/null/empty → []
527
+ // - object: { path|content, settings } → wrapped as a single 'system' segment
528
+ // - array: [{ role, path|content, settings }, ...] → role defaults to 'system'
529
+ // if omitted; invalid roles throw.
530
+ function normalizePrompt(input) {
531
+ const segments = Array.isArray(input)
532
+ ? input
533
+ : (input && (input.path || input.content || input.settings)) ? [input] : [];
534
+
535
+ return segments.map((segment) => {
536
+ const role = segment.role || 'system';
537
+
538
+ if (!VALID_PROMPT_ROLES.has(role)) {
539
+ throw new Error(`Invalid prompt role: ${role}. Valid roles: ${[...VALID_PROMPT_ROLES].join(', ')}`);
540
+ }
541
+
542
+ return {
543
+ role: role,
544
+ path: segment.path || '',
545
+ content: segment.content || '',
546
+ settings: segment.settings || {},
547
+ };
548
+ });
549
+ }
550
+
496
551
  function loadContent(input, _log) {
497
552
  // console.log('*** input!!!', input.content.slice(0, 50), input.path);
498
553
  // console.log('*** input.content', input.content.slice(0, 50));
@@ -674,16 +729,20 @@ function formatMessageContent(content, attachments, _log, mode = 'responses', ro
674
729
  }
675
730
 
676
731
 
677
- function formatHistory(options, prompt, message, _log) {
732
+ function formatHistory(options, promptSegments, message, _log) {
678
733
  // Get history with respect to the message limit
679
734
  const history = options.history.messages.slice(-options.history.limit);
680
735
 
681
- // Add prompt to beginning of history
682
- history.unshift({
683
- role: 'developer',
684
- content: prompt,
685
- attachments: [],
686
- });
736
+ // Add prompt segments to the beginning of history, in the order provided
737
+ // (each segment becomes its own message with the declared role)
738
+ for (let i = promptSegments.length - 1; i >= 0; i--) {
739
+ const segment = promptSegments[i];
740
+ history.unshift({
741
+ role: segment.role,
742
+ content: segment.content,
743
+ attachments: [],
744
+ });
745
+ }
687
746
 
688
747
  // Get last history item
689
748
  const lastHistory = history[history.length - 1];
@@ -722,7 +781,7 @@ function formatHistory(options, prompt, message, _log) {
722
781
  return formatted;
723
782
  }
724
783
 
725
- function attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log) {
784
+ function attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log) {
726
785
  const retries = options.retries;
727
786
  const triggers = options.retryTriggers;
728
787
 
@@ -733,7 +792,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
733
792
  _log(`Request ${attempt.count}/${retries}`);
734
793
 
735
794
  // Request
736
- makeRequest('responses', options, self, prompt, message, user, _log)
795
+ makeRequest('responses', options, self, promptSegments, message, user, _log)
737
796
  .then((r) => {
738
797
  // Example
739
798
  // {
@@ -834,7 +893,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
834
893
 
835
894
  // Retry
836
895
  if (attempt.count < retries && triggers.includes('parse')) {
837
- return attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log);
896
+ return attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
838
897
  }
839
898
 
840
899
  // Return
@@ -856,7 +915,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
856
915
 
857
916
  // Retry
858
917
  if (attempt.count < retries && triggers.includes('network')) {
859
- return attemptRequest(options, self, prompt, message, user, moderation, attempt, assistant, resolve, reject, _log);
918
+ return attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
860
919
  }
861
920
 
862
921
  // Return
@@ -864,7 +923,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
864
923
  });
865
924
  }
866
925
 
867
- function makeRequest(mode, options, self, prompt, message, user, _log) {
926
+ function makeRequest(mode, options, self, promptSegments, message, user, _log) {
868
927
  return new Promise(async function(resolve, reject) {
869
928
  const request = {
870
929
  url: '',
@@ -895,7 +954,7 @@ function makeRequest(mode, options, self, prompt, message, user, _log) {
895
954
  }
896
955
  } else if (mode === 'responses') {
897
956
  // Format history for responses API
898
- const history = formatHistory(options, prompt, message, _log);
957
+ const history = formatHistory(options, promptSegments, message, _log);
899
958
 
900
959
  // Set request
901
960
  request.url = 'https://api.openai.com/v1/responses';
@@ -1007,3 +1066,11 @@ function resolveReasoning(options) {
1007
1066
  }
1008
1067
 
1009
1068
  module.exports = OpenAI;
1069
+
1070
+ // Exposed for unit tests. Not part of the public API — do not rely on these
1071
+ // from consumer code.
1072
+ module.exports._internals = {
1073
+ normalizePrompt,
1074
+ formatHistory,
1075
+ VALID_PROMPT_ROLES,
1076
+ };
@@ -3081,6 +3081,7 @@
3081
3081
  "mannawo.com",
3082
3082
  "mansiondev.com",
3083
3083
  "maohe.cloud",
3084
+ "marineso.com",
3084
3085
  "mark-compressoren.ru",
3085
3086
  "marketlink.info",
3086
3087
  "markmurfin.com",
@@ -45,9 +45,9 @@ async function inferContactWithAI(email, assistant) {
45
45
  try {
46
46
  const ai = assistant.Manager.AI(assistant, process.env.BACKEND_MANAGER_OPENAI_API_KEY);
47
47
  const result = await ai.request({
48
- model: 'gpt-5-mini',
48
+ model: 'gpt-5.4-mini',
49
49
  timeout: 60000,
50
- maxTokens: 1024,
50
+ maxTokens: 1024 * 2,
51
51
  moderate: false,
52
52
  response: 'json',
53
53
  prompt: {
@@ -17,6 +17,13 @@ const POST_TEMPLATE = jetpack.read(`${__dirname}/templates/post.html`);
17
17
  const IMAGE_PATH_SRC = `src/assets/images/blog/post-{id}/`;
18
18
  const IMAGE_REGEX = /(?:!\[(.*?)\]\((.*?)\))/img;
19
19
 
20
+ // Max dimension (px) for downloaded post images on the long edge, and JPEG
21
+ // re-encode quality. Sources above the max cause downstream Jekyll/imagemin
22
+ // pipelines to stall on huge decodes (e.g. a 16384×10576 source decodes to
23
+ // ~520MB raw), so resize at ingest time.
24
+ const IMAGE_MAX_DIMENSION = 4096;
25
+ const IMAGE_JPEG_QUALITY = 80;
26
+
20
27
  module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
21
28
 
22
29
  // Require authentication
@@ -218,9 +225,47 @@ async function downloadImage(assistant, src, alt) {
218
225
  throw new Error(`Images must be .jpg (not ${result.ext})`);
219
226
  }
220
227
 
228
+ // Resize in place if the long edge exceeds IMAGE_MAX_DIMENSION
229
+ await resizeImage(assistant, result.path);
230
+
221
231
  return result;
222
232
  }
223
233
 
234
+ // Helper: Resize image in place if the long edge exceeds IMAGE_MAX_DIMENSION.
235
+ // Re-encodes as progressive JPEG at IMAGE_JPEG_QUALITY. Short-circuits when the
236
+ // source is already within the limit.
237
+ async function resizeImage(assistant, filepath) {
238
+ const sharp = assistant.Manager.require('sharp');
239
+
240
+ const meta = await sharp(filepath).metadata();
241
+ const longEdge = Math.max(meta.width, meta.height);
242
+
243
+ if (longEdge <= IMAGE_MAX_DIMENSION) {
244
+ assistant.log(`resizeImage(): No resize needed (${meta.width}x${meta.height})`);
245
+ return { resized: false, width: meta.width, height: meta.height };
246
+ }
247
+
248
+ // Resize to a buffer (cannot read+write the same path in one sharp pipeline)
249
+ const buffer = await sharp(filepath)
250
+ .resize({
251
+ width: IMAGE_MAX_DIMENSION,
252
+ height: IMAGE_MAX_DIMENSION,
253
+ fit: 'inside',
254
+ withoutEnlargement: true,
255
+ })
256
+ .jpeg({ quality: IMAGE_JPEG_QUALITY, progressive: true })
257
+ .toBuffer();
258
+
259
+ // Overwrite the file on disk
260
+ jetpack.write(filepath, buffer);
261
+
262
+ // Read the resized dimensions back for the log
263
+ const resizedMeta = await sharp(filepath).metadata();
264
+ assistant.log(`resizeImage(): Resized ${meta.width}x${meta.height} -> ${resizedMeta.width}x${resizedMeta.height} (max ${IMAGE_MAX_DIMENSION}px, q${IMAGE_JPEG_QUALITY})`);
265
+
266
+ return { resized: true, width: resizedMeta.width, height: resizedMeta.height };
267
+ }
268
+
224
269
  // Helper: Commit all files (images + post) in a single commit using Git Trees API
225
270
  async function commitAll(assistant, octokit, settings, files) {
226
271
  const owner = settings.githubUser;
@@ -326,3 +371,8 @@ function formatClone(payload) {
326
371
  return payload;
327
372
  }
328
373
 
374
+ // Expose helpers + constants for tests
375
+ module.exports.resizeImage = resizeImage;
376
+ module.exports.IMAGE_MAX_DIMENSION = IMAGE_MAX_DIMENSION;
377
+ module.exports.IMAGE_JPEG_QUALITY = IMAGE_JPEG_QUALITY;
378
+
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Test: AI request payload shape (libraries/ai/providers/openai.js)
3
+ *
4
+ * Verifies the transformation from the BEM-facing `ai.request()` options
5
+ * (specifically `options.prompt` in either legacy object form or array form)
6
+ * into the eventual OpenAI HTTP payload (the `input: [...]` array).
7
+ *
8
+ * These tests exercise the pure helpers `normalizePrompt` and `formatHistory`
9
+ * directly — no network, no assistant required.
10
+ */
11
+ const OpenAI = require('../../src/manager/libraries/ai/providers/openai.js');
12
+ const { normalizePrompt, formatHistory, VALID_PROMPT_ROLES } = OpenAI._internals;
13
+
14
+ function noopLog() {}
15
+
16
+ function baseOptions(overrides = {}) {
17
+ return {
18
+ dedupeConsecutiveRoles: true,
19
+ history: { messages: [], limit: 5 },
20
+ message: { attachments: [] },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ module.exports = {
26
+ description: 'AI request payload shape (system/developer/user roles)',
27
+ type: 'group',
28
+ tests: [
29
+ // ─── normalizePrompt ───
30
+
31
+ {
32
+ name: 'normalize-undefined-returns-empty-array',
33
+ async run({ assert }) {
34
+ assert.deepEqual(normalizePrompt(undefined), [], 'undefined → []');
35
+ },
36
+ },
37
+
38
+ {
39
+ name: 'normalize-null-returns-empty-array',
40
+ async run({ assert }) {
41
+ assert.deepEqual(normalizePrompt(null), [], 'null → []');
42
+ },
43
+ },
44
+
45
+ {
46
+ name: 'normalize-empty-object-returns-empty-array',
47
+ async run({ assert }) {
48
+ assert.deepEqual(normalizePrompt({}), [], 'empty object → []');
49
+ },
50
+ },
51
+
52
+ {
53
+ name: 'normalize-legacy-object-form-wraps-as-system-segment',
54
+ async run({ assert }) {
55
+ const result = normalizePrompt({ path: '/tmp/example.md', settings: { foo: 'bar' } });
56
+
57
+ assert.equal(result.length, 1, 'one segment');
58
+ assert.equal(result[0].role, 'system', 'legacy object defaults to system role');
59
+ assert.equal(result[0].path, '/tmp/example.md', 'path preserved');
60
+ assert.deepEqual(result[0].settings, { foo: 'bar' }, 'settings preserved');
61
+ },
62
+ },
63
+
64
+ {
65
+ name: 'normalize-legacy-object-with-content-only',
66
+ async run({ assert }) {
67
+ const result = normalizePrompt({ content: 'inline prompt text' });
68
+
69
+ assert.equal(result.length, 1, 'one segment');
70
+ assert.equal(result[0].role, 'system', 'defaults to system');
71
+ assert.equal(result[0].content, 'inline prompt text', 'content preserved');
72
+ assert.equal(result[0].path, '', 'no path');
73
+ },
74
+ },
75
+
76
+ {
77
+ name: 'normalize-array-form-preserves-roles-and-order',
78
+ async run({ assert }) {
79
+ const result = normalizePrompt([
80
+ { role: 'system', content: 'platform rules' },
81
+ { role: 'developer', content: 'operator config' },
82
+ ]);
83
+
84
+ assert.equal(result.length, 2, 'two segments');
85
+ assert.equal(result[0].role, 'system', 'first is system');
86
+ assert.equal(result[0].content, 'platform rules', 'first content');
87
+ assert.equal(result[1].role, 'developer', 'second is developer');
88
+ assert.equal(result[1].content, 'operator config', 'second content');
89
+ },
90
+ },
91
+
92
+ {
93
+ name: 'normalize-array-segment-without-role-defaults-to-system',
94
+ async run({ assert }) {
95
+ const result = normalizePrompt([
96
+ { content: 'rule 1' },
97
+ { role: 'developer', content: 'rule 2' },
98
+ ]);
99
+
100
+ assert.equal(result[0].role, 'system', 'missing role → system');
101
+ assert.equal(result[1].role, 'developer', 'explicit role preserved');
102
+ },
103
+ },
104
+
105
+ {
106
+ name: 'normalize-array-with-invalid-role-throws',
107
+ async run({ assert }) {
108
+ let threw = false;
109
+ try {
110
+ normalizePrompt([{ role: 'admin', content: 'bad' }]);
111
+ } catch (e) {
112
+ threw = true;
113
+ assert.equal(
114
+ String(e.message).includes('Invalid prompt role'),
115
+ true,
116
+ 'error mentions Invalid prompt role',
117
+ );
118
+ }
119
+ assert.equal(threw, true, 'should throw on invalid role');
120
+ },
121
+ },
122
+
123
+ {
124
+ name: 'normalize-valid-roles-set-matches-openai-model-spec',
125
+ async run({ assert }) {
126
+ const expected = ['system', 'developer', 'user', 'assistant'];
127
+ const actual = [...VALID_PROMPT_ROLES].sort();
128
+
129
+ assert.deepEqual(actual, expected.sort(), 'valid roles per OpenAI Model Spec');
130
+ },
131
+ },
132
+
133
+ {
134
+ name: 'normalize-all-valid-roles-accepted',
135
+ async run({ assert }) {
136
+ const segments = ['system', 'developer', 'user', 'assistant'].map((role) => ({
137
+ role,
138
+ content: `content for ${role}`,
139
+ }));
140
+
141
+ const result = normalizePrompt(segments);
142
+
143
+ assert.equal(result.length, 4, 'all four segments accepted');
144
+ result.forEach((segment, i) => {
145
+ assert.equal(segment.role, segments[i].role, `segment ${i} role preserved`);
146
+ });
147
+ },
148
+ },
149
+
150
+ // ─── formatHistory → OpenAI Responses API payload shape ───
151
+
152
+ {
153
+ name: 'format-single-system-prompt-emits-system-then-user',
154
+ async run({ assert }) {
155
+ const promptSegments = normalizePrompt({ content: 'You are a helpful assistant.' });
156
+ const formatted = formatHistory(baseOptions(), promptSegments, 'Hello!', noopLog);
157
+
158
+ assert.equal(formatted.length, 2, 'two messages: system + user');
159
+ assert.equal(formatted[0].role, 'system', 'first message is system');
160
+ assert.equal(formatted[0].content[0].type, 'input_text', 'system uses input_text');
161
+ assert.equal(formatted[0].content[0].text, 'You are a helpful assistant.', 'system text');
162
+ assert.equal(formatted[1].role, 'user', 'second message is user');
163
+ assert.equal(formatted[1].content[0].text, 'Hello!', 'user text');
164
+ },
165
+ },
166
+
167
+ {
168
+ name: 'format-system-plus-developer-emits-three-messages-in-order',
169
+ async run({ assert }) {
170
+ const promptSegments = normalizePrompt([
171
+ { role: 'system', content: 'Platform rules go here.' },
172
+ { role: 'developer', content: 'Operator config goes here.' },
173
+ ]);
174
+ const formatted = formatHistory(baseOptions(), promptSegments, 'Customer email body.', noopLog);
175
+
176
+ assert.equal(formatted.length, 3, 'three messages');
177
+ assert.equal(formatted[0].role, 'system', 'order: system');
178
+ assert.equal(formatted[1].role, 'developer', 'order: developer');
179
+ assert.equal(formatted[2].role, 'user', 'order: user');
180
+ assert.equal(formatted[0].content[0].text, 'Platform rules go here.', 'system content');
181
+ assert.equal(formatted[1].content[0].text, 'Operator config goes here.', 'developer content');
182
+ assert.equal(formatted[2].content[0].text, 'Customer email body.', 'user content');
183
+ },
184
+ },
185
+
186
+ {
187
+ name: 'format-empty-prompt-array-emits-only-user-message',
188
+ async run({ assert }) {
189
+ const formatted = formatHistory(baseOptions(), [], 'Just a user message.', noopLog);
190
+
191
+ assert.equal(formatted.length, 1, 'only the user message');
192
+ assert.equal(formatted[0].role, 'user', 'role: user');
193
+ assert.equal(formatted[0].content[0].text, 'Just a user message.', 'text preserved');
194
+ },
195
+ },
196
+
197
+ {
198
+ name: 'format-interleaves-prompt-history-and-new-user-message',
199
+ async run({ assert }) {
200
+ const promptSegments = normalizePrompt([
201
+ { role: 'system', content: 'system rules' },
202
+ { role: 'developer', content: 'developer rules' },
203
+ ]);
204
+ const options = baseOptions({
205
+ history: {
206
+ messages: [
207
+ { role: 'user', content: 'first user msg' },
208
+ { role: 'assistant', content: 'first ai reply' },
209
+ ],
210
+ limit: 5,
211
+ },
212
+ });
213
+ const formatted = formatHistory(options, promptSegments, 'second user msg', noopLog);
214
+
215
+ const roleSequence = formatted.map((m) => m.role);
216
+ assert.deepEqual(
217
+ roleSequence,
218
+ ['system', 'developer', 'user', 'assistant', 'user'],
219
+ 'prompts → history → new user message',
220
+ );
221
+ },
222
+ },
223
+
224
+ {
225
+ name: 'format-assistant-history-uses-output_text-type',
226
+ async run({ assert }) {
227
+ const options = baseOptions({
228
+ history: {
229
+ messages: [{ role: 'assistant', content: 'previous reply' }],
230
+ limit: 5,
231
+ },
232
+ });
233
+ const formatted = formatHistory(options, [], 'new message', noopLog);
234
+
235
+ const assistantMsg = formatted.find((m) => m.role === 'assistant');
236
+ assert.equal(assistantMsg.content[0].type, 'output_text', 'assistant uses output_text');
237
+ },
238
+ },
239
+
240
+ {
241
+ name: 'format-respects-history-limit',
242
+ async run({ assert }) {
243
+ const options = baseOptions({
244
+ history: {
245
+ messages: [
246
+ { role: 'user', content: 'msg 1' },
247
+ { role: 'assistant', content: 'msg 2' },
248
+ { role: 'user', content: 'msg 3' },
249
+ { role: 'assistant', content: 'msg 4' },
250
+ { role: 'user', content: 'msg 5' },
251
+ { role: 'assistant', content: 'msg 6' },
252
+ ],
253
+ limit: 2,
254
+ },
255
+ });
256
+ const formatted = formatHistory(options, normalizePrompt({ content: 'sys' }), 'now', noopLog);
257
+
258
+ // Expected: 1 system + 2 history + 1 user = 4 messages
259
+ assert.equal(formatted.length, 4, 'system + 2 history + new user');
260
+ assert.equal(formatted[1].content[0].text, 'msg 5', 'second-to-last history kept');
261
+ assert.equal(formatted[2].content[0].text, 'msg 6', 'last history kept');
262
+ assert.equal(formatted[3].content[0].text, 'now', 'new user message appended');
263
+ },
264
+ },
265
+
266
+ {
267
+ name: 'format-dedupes-trailing-user-history-when-flag-set',
268
+ async run({ assert }) {
269
+ const options = baseOptions({
270
+ dedupeConsecutiveRoles: true,
271
+ history: {
272
+ messages: [
273
+ { role: 'assistant', content: 'reply' },
274
+ { role: 'user', content: 'should be dropped' },
275
+ ],
276
+ limit: 5,
277
+ },
278
+ });
279
+ const formatted = formatHistory(options, [], 'real new message', noopLog);
280
+
281
+ // history's trailing 'user' is dropped, then the real new message is appended
282
+ assert.equal(formatted.length, 2, 'assistant + new user only');
283
+ assert.equal(formatted[0].role, 'assistant', 'kept assistant');
284
+ assert.equal(formatted[1].role, 'user', 'new user');
285
+ assert.equal(formatted[1].content[0].text, 'real new message', 'new user content');
286
+ },
287
+ },
288
+
289
+ {
290
+ name: 'format-strips-and-trims-content',
291
+ async run({ assert }) {
292
+ const promptSegments = normalizePrompt({ content: ' padded system content \n' });
293
+ const formatted = formatHistory(baseOptions(), promptSegments, ' padded user content ', noopLog);
294
+
295
+ assert.equal(formatted[0].content[0].text, 'padded system content', 'system trimmed');
296
+ assert.equal(formatted[1].content[0].text, 'padded user content', 'user trimmed');
297
+ },
298
+ },
299
+ ],
300
+ };
@@ -5,6 +5,9 @@
5
5
  * Requires admin/blogger role, GitHub API key, and repo_website config
6
6
  */
7
7
  const { Octokit } = require('@octokit/rest');
8
+ const sharp = require('sharp');
9
+
10
+ const { IMAGE_MAX_DIMENSION } = require('../../../src/manager/routes/admin/post/post');
8
11
 
9
12
  module.exports = {
10
13
  description: 'Admin create post on GitHub',
@@ -130,12 +133,15 @@ module.exports = {
130
133
 
131
134
  // Use a real public .jpg for the inline image
132
135
  const inlineImageURL = 'https://picsum.photos/id/10/200/200.jpg';
136
+ // Oversized header image (long edge 5000px > IMAGE_MAX_DIMENSION 4096px) to
137
+ // verify the resize step runs end-to-end and the committed file is clamped.
138
+ const headerImageURL = 'https://picsum.photos/id/1/5000/3000.jpg';
133
139
 
134
140
  const response = await http.post('admin/post', {
135
141
  title: 'BEM Test Create Post',
136
142
  url: 'bem-test-create-post',
137
143
  description: 'Test post created by BEM test suite to verify @post/ body rewriting.',
138
- headerImageURL: 'https://picsum.photos/id/1/400/300.jpg',
144
+ headerImageURL: headerImageURL,
139
145
  body: `# BEM Test Create Post\n\nSome intro text.\n\n![Test inline image](${inlineImageURL})\n\nMore text after the image.`,
140
146
  postPath: 'guest',
141
147
  });
@@ -186,6 +192,53 @@ module.exports = {
186
192
  },
187
193
  },
188
194
 
195
+ {
196
+ name: 'verify-oversized-header-image-was-resized',
197
+ auth: 'admin',
198
+ timeout: 30000,
199
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set' : false,
200
+
201
+ async run({ assert, state }) {
202
+ if (!state.postId) {
203
+ return; // Previous test didn't run
204
+ }
205
+
206
+ const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
207
+ const imageDir = `src/assets/images/blog/post-${state.postId}/`;
208
+
209
+ // List committed images and pick the header (matches the slugified URL "bem-test-create-post")
210
+ const { data: dirData } = await octokit.rest.repos.getContent({
211
+ owner: state.owner,
212
+ repo: state.repo,
213
+ path: imageDir,
214
+ });
215
+
216
+ const headerFile = dirData.find((f) => f.name === 'bem-test-create-post.jpg');
217
+ assert.ok(headerFile, 'Header image should be committed at expected slug');
218
+
219
+ // Fetch the raw bytes and read dimensions
220
+ const { data: blob } = await octokit.rest.git.getBlob({
221
+ owner: state.owner,
222
+ repo: state.repo,
223
+ file_sha: headerFile.sha,
224
+ });
225
+
226
+ const buffer = Buffer.from(blob.content, 'base64');
227
+ const meta = await sharp(buffer).metadata();
228
+
229
+ // The source we requested was 5000x3000 (over the 4096 limit).
230
+ // After resize, the long edge should be clamped to IMAGE_MAX_DIMENSION.
231
+ const longEdge = Math.max(meta.width, meta.height);
232
+ assert.ok(
233
+ longEdge <= IMAGE_MAX_DIMENSION,
234
+ `Committed header image long edge (${meta.width}x${meta.height}) should be <= IMAGE_MAX_DIMENSION (${IMAGE_MAX_DIMENSION})`,
235
+ );
236
+
237
+ // And the resize should actually have happened (source was 5000x3000 → expect width=4096)
238
+ assert.equal(meta.width, IMAGE_MAX_DIMENSION, 'Resized width should equal IMAGE_MAX_DIMENSION');
239
+ },
240
+ },
241
+
189
242
  // --- Auth rejection tests ---
190
243
 
191
244
  {
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Test: routes/admin/post/post.resizeImage
3
+ * Unit tests for the in-place image resize used by the admin/post route.
4
+ *
5
+ * Run: npx mgr test routes/admin/post-resize-image
6
+ *
7
+ * Contract:
8
+ * - Images with both dimensions <= IMAGE_MAX_DIMENSION pass through untouched.
9
+ * - Images with either dimension > IMAGE_MAX_DIMENSION are resized in place,
10
+ * preserving aspect ratio, with the long edge clamped to IMAGE_MAX_DIMENSION.
11
+ * - Resize re-encodes as JPEG at IMAGE_JPEG_QUALITY (lossy is expected).
12
+ * - The file at the original path is overwritten with the resized bytes.
13
+ */
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const jetpack = require('fs-jetpack');
17
+ const sharp = require('sharp');
18
+
19
+ const post = require('../../../src/manager/routes/admin/post/post');
20
+
21
+ const { resizeImage, IMAGE_MAX_DIMENSION, IMAGE_JPEG_QUALITY } = post;
22
+
23
+ // Generate a synthetic JPEG of the given dimensions and write it to a tmp path.
24
+ // Returns the absolute path on disk.
25
+ async function makeJpeg(width, height) {
26
+ const filepath = path.join(os.tmpdir(), `bem-test-resize-${Date.now()}-${width}x${height}.jpg`);
27
+ const buffer = await sharp({
28
+ create: {
29
+ width: width,
30
+ height: height,
31
+ channels: 3,
32
+ background: { r: 128, g: 128, b: 128 },
33
+ },
34
+ })
35
+ .jpeg({ quality: 90 })
36
+ .toBuffer();
37
+
38
+ jetpack.write(filepath, buffer);
39
+ return filepath;
40
+ }
41
+
42
+ // Minimal assistant stub — resizeImage only uses Manager.require + log.
43
+ function makeAssistant() {
44
+ return {
45
+ log: () => {},
46
+ Manager: {
47
+ require: (mod) => require(mod),
48
+ },
49
+ };
50
+ }
51
+
52
+ module.exports = {
53
+ description: 'routes/admin/post/post.resizeImage',
54
+ type: 'group',
55
+
56
+ tests: [
57
+ // ─── Constants exposed ───
58
+
59
+ {
60
+ name: 'exports-constants',
61
+ async run({ assert }) {
62
+ assert.equal(IMAGE_MAX_DIMENSION, 4096, 'IMAGE_MAX_DIMENSION should be 4096');
63
+ assert.equal(IMAGE_JPEG_QUALITY, 80, 'IMAGE_JPEG_QUALITY should be 80');
64
+ assert.isType(resizeImage, 'function', 'resizeImage should be exported as a function');
65
+ },
66
+ },
67
+
68
+ // ─── Pass-through: image already within bounds ───
69
+
70
+ {
71
+ name: 'small-image-passes-through-untouched',
72
+ async run({ assert }) {
73
+ const filepath = await makeJpeg(800, 600);
74
+ const sizeBefore = jetpack.inspect(filepath).size;
75
+
76
+ const result = await resizeImage(makeAssistant(), filepath);
77
+
78
+ assert.equal(result.resized, false, 'Small image should not be resized');
79
+ assert.equal(result.width, 800, 'Width should be reported as-is');
80
+ assert.equal(result.height, 600, 'Height should be reported as-is');
81
+
82
+ const sizeAfter = jetpack.inspect(filepath).size;
83
+ assert.equal(sizeAfter, sizeBefore, 'File on disk should be byte-identical (no re-encode)');
84
+
85
+ jetpack.remove(filepath);
86
+ },
87
+ },
88
+
89
+ {
90
+ name: 'image-exactly-at-max-passes-through',
91
+ async run({ assert }) {
92
+ // Long edge exactly === IMAGE_MAX_DIMENSION → no resize (boundary condition)
93
+ const filepath = await makeJpeg(IMAGE_MAX_DIMENSION, 2000);
94
+
95
+ const result = await resizeImage(makeAssistant(), filepath);
96
+
97
+ assert.equal(result.resized, false, 'Image exactly at the limit should not be resized');
98
+ assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Width unchanged');
99
+
100
+ jetpack.remove(filepath);
101
+ },
102
+ },
103
+
104
+ // ─── Resize: landscape (width > height) ───
105
+
106
+ {
107
+ name: 'landscape-oversized-is-resized-to-max-width',
108
+ async run({ assert }) {
109
+ // 8000x4000 landscape → long edge is width → clamp width to 4096, scale height proportionally to 2048
110
+ const filepath = await makeJpeg(8000, 4000);
111
+
112
+ const result = await resizeImage(makeAssistant(), filepath);
113
+
114
+ assert.equal(result.resized, true, 'Oversized landscape should be resized');
115
+ assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Width clamped to IMAGE_MAX_DIMENSION');
116
+ assert.equal(result.height, IMAGE_MAX_DIMENSION / 2, 'Height scaled proportionally (8000:4000 → 4096:2048)');
117
+
118
+ // Verify the file on disk was actually overwritten
119
+ const meta = await sharp(filepath).metadata();
120
+ assert.equal(meta.width, IMAGE_MAX_DIMENSION, 'On-disk width matches reported width');
121
+ assert.equal(meta.height, IMAGE_MAX_DIMENSION / 2, 'On-disk height matches reported height');
122
+ assert.equal(meta.format, 'jpeg', 'On-disk format is JPEG');
123
+
124
+ jetpack.remove(filepath);
125
+ },
126
+ },
127
+
128
+ // ─── Resize: portrait (height > width) ───
129
+
130
+ {
131
+ name: 'portrait-oversized-is-resized-to-max-height',
132
+ async run({ assert }) {
133
+ // 4000x8000 portrait → long edge is height → clamp height to 4096, width proportionally to 2048
134
+ const filepath = await makeJpeg(4000, 8000);
135
+
136
+ const result = await resizeImage(makeAssistant(), filepath);
137
+
138
+ assert.equal(result.resized, true, 'Oversized portrait should be resized');
139
+ assert.equal(result.width, IMAGE_MAX_DIMENSION / 2, 'Width scaled proportionally');
140
+ assert.equal(result.height, IMAGE_MAX_DIMENSION, 'Height clamped to IMAGE_MAX_DIMENSION');
141
+
142
+ jetpack.remove(filepath);
143
+ },
144
+ },
145
+
146
+ // ─── Resize: huge image (the bug we are guarding against) ───
147
+
148
+ {
149
+ name: 'huge-image-is-resized-down',
150
+ async run({ assert }) {
151
+ // 16384x10576 — the actual size from post-1779087609 (the-importance-of-feedback-loops).
152
+ // Raw RGB this size is ~520MB, which is what stalled UJM's imagemin stream.
153
+ const filepath = await makeJpeg(16384, 10576);
154
+ const sizeBefore = jetpack.inspect(filepath).size;
155
+
156
+ const result = await resizeImage(makeAssistant(), filepath);
157
+
158
+ assert.equal(result.resized, true, 'Huge image should be resized');
159
+ assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Long edge clamped to IMAGE_MAX_DIMENSION');
160
+ // 16384:10576 ratio → height = 4096 * (10576/16384) = 2644
161
+ assert.inRange(result.height, 2643, 2645, 'Height scaled proportionally (within rounding)');
162
+
163
+ const sizeAfter = jetpack.inspect(filepath).size;
164
+ assert.ok(sizeAfter < sizeBefore, 'Resized file on disk should be smaller than original');
165
+
166
+ jetpack.remove(filepath);
167
+ },
168
+ },
169
+
170
+ // ─── Square images ───
171
+
172
+ {
173
+ name: 'square-oversized-is-resized-to-square',
174
+ async run({ assert }) {
175
+ const filepath = await makeJpeg(8000, 8000);
176
+
177
+ const result = await resizeImage(makeAssistant(), filepath);
178
+
179
+ assert.equal(result.resized, true, 'Oversized square should be resized');
180
+ assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Width clamped');
181
+ assert.equal(result.height, IMAGE_MAX_DIMENSION, 'Height clamped');
182
+
183
+ jetpack.remove(filepath);
184
+ },
185
+ },
186
+ ],
187
+ };