mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix(media): mux container-correct codecs in video tools Video tools hardcoded H.264 (and AAC) while keeping the input's container extension, so a .webm input produced an invalid file (ffmpeg exit 234: H.264 cannot be muxed into WebM). Add shared videoEncodeArgsForContainer/audioEncodeArgsForContainer helpers (vp9+opus for webm, theora+vorbis for ogv, h264+aac otherwise) and apply them across 14 tools; re-encode audio to AAC in burn-subtitles (forced mp4). Adds a webm regression test for change-fps. * fix(eraser): recover Object Eraser when its progress SSE drops The eraser used a bespoke EventSource with no recovery, so a dropped SSE left the UI stuck at ~25% forever even though the backend job had finished and saved its result. Add a resilient subscription (reconnect on tab refocus, which replays the cached terminal frame; 5-minute stall timeout) mirroring the standard processor's PR #203/#204 recovery. * feat(ui): rename the Documents modality to PDF and Data to Files Updates modality display names, the home-page tabs, the tool-page breadcrumb, and the homePage.documents/data + modalities labels across all 21 locales. URL slugs are unchanged for link stability. * feat(compress-pdf): add quality and target-size compression modes Mirror the image Compress tool: a quality slider (1-100) and a target file size, replacing the screen/ebook/printer preset. Adds gsCompressPdfQuality to doc-engine (quality maps to image downsample DPI, the dominant size lever for PDFs); target-size binary-searches the DPI for the highest quality under the target. The frontend reuses the shared CompressControls component, so no new translation strings are needed. * feat(ocr-pdf): show the PDF preview and extracted text side by side ocr-pdf fell back to the image viewer, which cannot render a PDF, so the right pane showed 'Preview not available' and the extracted text was only a download. It now uses a custom results view (custom-results display mode) rendering the input PDF via pdf.js (DocumentView gains an inputOnly prop, since the tool's output is a .txt) next to the extracted OCR text, with a copy button. * feat(ui): link the modality breadcrumb to its tools tab The modality segment of the tool breadcrumb (PDF, Image, Video, Audio, Files) is now a link to /?modality=<tab>. The home page reads the param, activates the matching tab, and cleans the URL, so it returns to the existing Tools page filtered to that modality without a new page. Handles the file modality whose tab key is 'data'. * feat(circle-crop): add zoom/offset framing, border, background, and output size Upgrade the circle-crop tool from a bare centered crop into a framing and styling tool. New settings (all backward-compatible with the old empty payload): - zoom (1-5x) plus offsetX/offsetY (0-1) to control how tight the circle is and where it sits in the source image - borderWidth (0-200px) plus borderColor for an optional ring - background: transparent (clear corners) or a hex fill - outputSize for a square output; omitted keeps native size The settings panel gains an inline draggable circular preview that mirrors the framing live, a zoom slider, a border slider with color, a transparent/color background toggle, and an output-size field. Adds an integration test covering output size, border, and a solid background. * feat(image-tools): flesh out five thin tools (gif-webp, histogram, favicon, color-palette, lqip) Tier A of the image-tool depth pass. Each of these was as bare as the old circle-crop (empty settings, opaque or invisible output). Now: - gif-webp: quality, lossless, and resize-percent controls; shows before/after size - histogram: returns full per-channel bins + stats; the settings panel renders an inline interactive histogram with R/G/B/Luma toggles, linear/log scale, and a mean/median/stdev readout (server PNG still downloadable) - favicon: background fill, padding, corner-radius, theme color, and a per-size checklist, with a live preview grid; the route applies the styling and honors the size filter - color-palette: count (2-16) and hex/rgb/hsl format controls, median-cut extraction, a palette strip, and CSS/JSON export - lqip-placeholder: blur/pixelate/solid strategies, format and quality; the output panel now surfaces the data URI with copy plus HTML/CSS snippets and a preview (previously the deliverable was never shown) Also expose resultPayload from useToolProcessor so a tool can render the route's extra result fields (histogram bins, lqip data URI) in its own panel. Updates the five integration tests to cover the new settings. * feat(image-tools): deepen five thin tools (duotone, vignette, pixelate, background-replace, blur-background) Tier B of the image-tool depth pass. - duotone: preset palettes, an intensity slider that blends the duotone with the original, and a true live duotone preview (a self-contained grayscale + lighten/darken overlay so the pane filter cannot wash it out) - vignette: radius, softness, roundness, and center-x/y controls driving a rebuilt radial gradient, with a matching live overlay - pixelate: a selection mode that exposes the route's region support via a draggable box over the image plus width/height sliders, so a face or plate can be pixelated in isolation - background-replace: gradient backgrounds, edge feather, and webp output on top of the existing solid color; now shown before/after - blur-background: edge feather and webp output; now shown before/after The live previews for duotone and vignette needed onImageStyle to mount the overlay branch in image-viewer. The duotone intensity blend and both AI tools' edge feather were rewritten to splice the alpha channel through raw buffers; joinChannel did not reliably re-tag the merged channel as alpha and a raw-without-encoder buffer broke the next decode. Updates the five integration tests. * fix(data): rename Files modality to Data + 20 Data-tool bug fixes (#247) * fix(ui): restore the Data modality name (revert Files rename) The 'file' modality reverts to the 'Data' label in modality.ts, the home-page tab, and the tools.data + documentsAndFiles i18n keys across all 21 locales. The separate Documents to PDF rename is kept. The URL slug was already /data, so name and slug realign; the tool breadcrumb follows modality.ts automatically. * fix(create-zip): require at least two files before enabling submit create-zip enabled its submit button with a single file, but the backend rejects fewer than two files ('Zipping needs at least two files'), producing a 422 error. Gate the button on files.length >= 2 to match the sibling merge-csvs tool. Found during the Data-modality QA sweep. * fix(data): resolve 17 bugs found in a deeper Data-tool review Crashes (threw an internal error on otherwise-valid input): - csv-json: a primitive JSON array like [1,2,3] threw "Unable to serialize"; now a clear error. - json-xml: a null or primitive JSON root crashed the XML builder; now a clear 4xx. - yaml-json: an empty or comment-only YAML returned undefined and threw on Buffer.from; now emits null. Data loss / wrong output: - csv-json: nested objects rendered as "[object Object]" (now serialized to JSON); heterogeneous objects dropped columns (now the union of all keys). - xml-to-csv: leaked fast-xml-parser markers ("@_" on attributes, "#text") into CSV headers (now cleaned); a single-record XML failed to tabulate (now a 1-row table); heterogeneous records dropped columns (now the union of all keys). - csv-excel: xlsx date cells were rendered in the server timezone via Date.toString (now ISO 8601, round-trippable). - create-zip and extract-zip: filename/basename collisions overwrote zip entries and silently lost a file; dedup now checks generated names and guarantees uniqueness. - chart-maker: negative values produced invalid/degenerate SVG that Sharp silently dropped; now rejected with a clear message. Empty output / validation: - split-csv: a header-only CSV produced an empty zip; now errors with "No data rows to split". - extract-zip: a directory-only zip produced an empty zip; now errors with "No extractable files found". - create-zip and merge-csvs: a single-file request fell through to the worker and returned 422; the factory now supports minInputs and returns 400 pre-enqueue. UI: - review-panel: the result card showed "Saved +X%" when the output grew; the savings row now appears only when the file is actually smaller (Original/Processed sizes always shown). Found via two adversarial code-review passes over the 10 Data routes. All 24 fix + regression checks pass against a fresh Docker stack on :1359. * fix(data): clean 400 for unsafe-zip entries; drop header on split keepHeader=false - tool-factory: add an opt-in preValidate hook that runs after input prep and before enqueue. Throwing InputValidationError there returns its statusCode (400) instead of the worker's generic 422. BullMQ loses the error class across the job boundary, so InputValidationErrors thrown in the worker cannot be mapped to their status; pre-enqueue validation can. - extract-zip: validate entry paths via preValidate, rejecting path-traversal and absolute-path archives (and unreadable/corrupt zips) with a clear 400. The processV2 guards remain as defense-in-depth for the pipeline/batch path. - split-csv: keepHeader=false now drops the header (parts contain only data rows) instead of keeping it as the first data row of part-1. Verified against a fresh Docker stack: unsafe / absolute / corrupt zips -> 400, normal zip still 200; split keepHeader=false drops the header while true repeats it in each part. No regressions across 51 fix + scenario checks. * feat(image-tools): deepen image-pad and sprite-sheet, fix sprite-sheet multi-file submit Tier C of the image-tool depth pass. - image-pad: a custom W:H ratio alongside the presets, a background mode (solid color, transparent, or an Instagram-style blurred cover fill), and an extra padding margin. The settings panel gains a real live preview of the padded canvas (it previously declared live-preview but rendered nothing) via onImageStyle + onImageOverlay. - sprite-sheet: PNG/WebP/JPEG output with a quality control, and the coordinate map it already computes is now returned and surfaced as Copy CSS (per-frame background-position rules) and Copy JSON exports. Also fix a pre-existing sprite-sheet bug: with more than one image the panel called processAllFiles, fanning out to the per-file batch route (422). It now calls processFiles, which packs all images into a single sheet request (it is a MULTI_FILE tool). Updates both integration tests. * fix(media): preserve source sample rate after loudnorm (#243) ffmpeg's loudnorm filter runs internally at 192 kHz and emits 192 kHz unless the chain resamples back. normalize-audio and video-loudnorm therefore produced 192 kHz output (4.3x larger files) regardless of the input rate. Append aresample to restore the input's sample rate. runMediaTool now exposes the input audio sample rate to its args callback. * fix(color-palette): collapse solid-color images to one swatch The median-cut bucket selector started bestRange at -1, so a uniform bucket (range 0) still satisfied the > comparison and kept splitting, yielding N identical swatches for a solid-color image. Start at 0 so only buckets with real color spread are split. * fix(lint): annotate implicit-any lets in saml and user-files biome noImplicitAnyLet flagged the bare let in saml.ts (profile) and user-files.ts (stream); add derived type annotations (type-only, no behavior change). Latent on main via the turbo lint cache; surfaced when the Data changes busted the apps/api lint cache.
1130 lines
43 KiB
TypeScript
1130 lines
43 KiB
TypeScript
/**
|
|
* Integration tests for the favicon tool.
|
|
*
|
|
* Generates a ZIP archive containing favicon assets (multiple PNG sizes,
|
|
* ICO, manifest.json, HTML snippet). Custom route that streams a ZIP
|
|
* response via reply.hijack().
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import AdmZip from "adm-zip";
|
|
import sharp from "sharp";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
|
|
|
/** Check if ImageMagick is available on this system. */
|
|
function checkMagickAvailable(): boolean {
|
|
try {
|
|
execFileSync("magick", ["--version"], { timeout: 5_000, stdio: "ignore" });
|
|
return true;
|
|
} catch {
|
|
try {
|
|
execFileSync("convert", ["--version"], { timeout: 5_000, stdio: "ignore" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const HAS_MAGICK = checkMagickAvailable();
|
|
|
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
|
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
|
|
|
let testApp: TestApp;
|
|
let app: TestApp["app"];
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
describe("favicon", () => {
|
|
it("generates favicon ZIP from a single image", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("application/zip");
|
|
|
|
// Parse the ZIP and verify expected files
|
|
const zipBuffer = Buffer.from(res.rawPayload);
|
|
const zip = new AdmZip(zipBuffer);
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("favicon-48x48.png");
|
|
expect(entries).toContain("apple-touch-icon.png");
|
|
expect(entries).toContain("android-chrome-192x192.png");
|
|
expect(entries).toContain("android-chrome-512x512.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
expect(entries).toContain("manifest.json");
|
|
expect(entries).toContain("favicon-snippet.html");
|
|
});
|
|
|
|
it("manifest.json is valid JSON", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const manifestEntry = zip.getEntry("manifest.json");
|
|
expect(manifestEntry).toBeDefined();
|
|
const manifest = JSON.parse(manifestEntry?.getData().toString("utf-8"));
|
|
expect(manifest.icons).toBeDefined();
|
|
expect(manifest.icons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("HTML snippet contains link tags", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const snippetEntry = zip.getEntry("favicon-snippet.html");
|
|
expect(snippetEntry).toBeDefined();
|
|
const html = snippetEntry?.getData().toString("utf-8");
|
|
expect(html).toContain('<link rel="icon"');
|
|
expect(html).toContain('<link rel="apple-touch-icon"');
|
|
expect(html).toContain('<link rel="manifest"');
|
|
});
|
|
|
|
it("favicon PNG entries have non-zero size", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
for (const name of ["favicon-16x16.png", "favicon-32x32.png", "favicon.ico"]) {
|
|
const entry = zip.getEntry(name);
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.header.size).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("uses subfolder structure for multiple images", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "icon1.png", contentType: "image/png", content: PNG },
|
|
{ name: "file", filename: "icon2.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
// Multiple files get per-image subfolder prefixes
|
|
expect(entries.some((e) => e.startsWith("icon1/"))).toBe(true);
|
|
expect(entries.some((e) => e.startsWith("icon2/"))).toBe(true);
|
|
});
|
|
|
|
it("rejects request without any files", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toContain("No image file");
|
|
});
|
|
|
|
// ── Additional coverage: SVG input ─────────────────────────────
|
|
it("generates favicons from SVG input", async () => {
|
|
const SVG = readFileSync(join(FIXTURES, "test-100x100.svg"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.svg", contentType: "image/svg+xml", content: SVG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("application/zip");
|
|
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("android-chrome-512x512.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
});
|
|
|
|
// ── Additional coverage: JPEG input ────────────────────────────
|
|
it("generates favicons from JPEG input", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "logo.jpg",
|
|
contentType: "image/jpeg",
|
|
content: readFileSync(join(FIXTURES, "test-100x100.jpg")),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
});
|
|
|
|
// ── Additional coverage: WebP input ────────────────────────────
|
|
it("generates favicons from WebP input", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "logo.webp",
|
|
contentType: "image/webp",
|
|
content: readFileSync(join(FIXTURES, "test-50x50.webp")),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("android-chrome-192x192.png");
|
|
expect(entries).toContain("manifest.json");
|
|
});
|
|
|
|
// ── Additional coverage: HEIC input ────────────────────────────
|
|
it("generates favicons from HEIC input", { timeout: 120_000 }, async () => {
|
|
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
});
|
|
|
|
// ── Additional coverage: favicon PNG sizes are correct ─────────
|
|
it("generates correct pixel dimensions for each favicon size", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
|
|
const expectedSizes: Record<string, number> = {
|
|
"favicon-16x16.png": 16,
|
|
"favicon-32x32.png": 32,
|
|
"favicon-48x48.png": 48,
|
|
"apple-touch-icon.png": 180,
|
|
"android-chrome-192x192.png": 192,
|
|
"android-chrome-512x512.png": 512,
|
|
};
|
|
|
|
for (const [name, expectedSize] of Object.entries(expectedSizes)) {
|
|
const entry = zip.getEntry(name);
|
|
expect(entry).toBeDefined();
|
|
const meta = await sharp(entry?.getData()).metadata();
|
|
expect(meta.width).toBe(expectedSize);
|
|
expect(meta.height).toBe(expectedSize);
|
|
}
|
|
});
|
|
|
|
// ── Additional coverage: manifest.json contains app name ──────
|
|
it("manifest.json uses the file stem as app name", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "my-app-logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const manifest = JSON.parse(zip.getEntry("manifest.json")?.getData().toString("utf-8"));
|
|
expect(manifest.name).toBe("my-app-logo");
|
|
expect(manifest.short_name).toBe("my-app-logo");
|
|
expect(manifest.theme_color).toBe("#ffffff");
|
|
expect(manifest.display).toBe("standalone");
|
|
});
|
|
|
|
// ── Additional coverage: invalid settings JSON ─────────────────
|
|
it("rejects invalid settings JSON", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
{ name: "settings", content: "not-json" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toContain("Settings must be valid JSON");
|
|
});
|
|
|
|
// ── Additional coverage: invalid image data ────────────────────
|
|
it("rejects corrupted image data", async () => {
|
|
const badBuffer = Buffer.from("this is not an image");
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "bad.png", contentType: "image/png", content: badBuffer },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toContain("Invalid file");
|
|
});
|
|
|
|
// ── Additional coverage: Content-Disposition header ────────────
|
|
it("response has correct Content-Disposition header", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const disposition = res.headers["content-disposition"] as string;
|
|
expect(disposition).toContain("attachment");
|
|
expect(disposition).toContain("favicons-");
|
|
expect(disposition).toContain(".zip");
|
|
});
|
|
|
|
// ── Additional coverage: subfolder manifest per image ──────────
|
|
it("each subfolder in multi-image has its own manifest and snippet", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "brand1.png", contentType: "image/png", content: PNG },
|
|
{ name: "file", filename: "brand2.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("brand1/manifest.json");
|
|
expect(entries).toContain("brand2/manifest.json");
|
|
expect(entries).toContain("brand1/favicon-snippet.html");
|
|
expect(entries).toContain("brand2/favicon-snippet.html");
|
|
expect(entries).toContain("brand1/favicon.ico");
|
|
expect(entries).toContain("brand2/favicon.ico");
|
|
});
|
|
|
|
// ── Branch coverage: line 33 (multipart parse error catch) ────────
|
|
// The multipart parse error (lines 48-53) triggers on malformed streams
|
|
// which is very hard to synthesize in inject(). The settings path is
|
|
// already covered by the invalid JSON test above.
|
|
|
|
// ── Branch coverage: lines 74-77 (settings JSON parse catch) ──────
|
|
// Already covered by "rejects invalid settings JSON" test above.
|
|
|
|
// ── Branch coverage: lines 144-150 (processing error after hijack) ──
|
|
// This branch is the catch block when processing fails AFTER reply.hijack()
|
|
// has already been called. When headers have been sent, the code skips
|
|
// the 422 reply. We test the pre-hijack path here (corrupted data
|
|
// that fails file validation triggers before hijack).
|
|
|
|
// ── Tiny 1x1 input ───────────────────────────────────────────────
|
|
|
|
it("generates favicons from a 1x1 pixel image", async () => {
|
|
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("android-chrome-512x512.png");
|
|
|
|
// Verify the 16x16 is actually 16x16 even from a 1x1 source
|
|
const entry16 = zip.getEntry("favicon-16x16.png");
|
|
expect(entry16).toBeDefined();
|
|
const meta = await sharp(entry16?.getData()).metadata();
|
|
expect(meta.width).toBe(16);
|
|
expect(meta.height).toBe(16);
|
|
});
|
|
|
|
// ── Large stress file ─────────────────────────────────────────────
|
|
|
|
it("generates favicons from stress-large.jpg", async () => {
|
|
const LARGE = readFileSync(join(FIXTURES, "content", "stress-large.jpg"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "large.jpg", contentType: "image/jpeg", content: LARGE },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("android-chrome-512x512.png");
|
|
});
|
|
|
|
// ── Portrait HEIC input ───────────────────────────────────────────
|
|
|
|
it("generates favicons from portrait HEIC", { timeout: 120_000 }, async () => {
|
|
const HEIC_PORTRAIT = readFileSync(join(FIXTURES, "test-portrait.heic"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "portrait.heic",
|
|
contentType: "image/heic",
|
|
content: HEIC_PORTRAIT,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
|
|
// Square output even from portrait input (fit: cover)
|
|
const entry32 = zip.getEntry("favicon-32x32.png");
|
|
const meta = await sharp(entry32?.getData()).metadata();
|
|
expect(meta.width).toBe(32);
|
|
expect(meta.height).toBe(32);
|
|
});
|
|
|
|
// ── Empty settings object ─────────────────────────────────────────
|
|
|
|
it("accepts empty settings object", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
});
|
|
|
|
// ── No settings field at all ──────────────────────────────────────
|
|
|
|
it("works when no settings field is provided", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("manifest.json");
|
|
});
|
|
|
|
// ── Rejects unauthenticated request ──────────────────────────────
|
|
|
|
it("rejects unauthenticated request", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
// ── ICO file is a valid PNG (simple ICO format) ──────────────────
|
|
|
|
it("favicon.ico entry has non-zero content", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const icoEntry = zip.getEntry("favicon.ico");
|
|
expect(icoEntry).toBeDefined();
|
|
const icoData = icoEntry!.getData();
|
|
expect(icoData.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Three images produce three subfolders ────────────────────────
|
|
|
|
it("generates favicons for 3 images in subfolders", async () => {
|
|
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
|
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "brand1.png", contentType: "image/png", content: PNG },
|
|
{ name: "file", filename: "brand2.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "file", filename: "brand3.webp", contentType: "image/webp", content: WEBP },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries.some((e) => e.startsWith("brand1/"))).toBe(true);
|
|
expect(entries.some((e) => e.startsWith("brand2/"))).toBe(true);
|
|
expect(entries.some((e) => e.startsWith("brand3/"))).toBe(true);
|
|
});
|
|
|
|
// ── Single file with dot in name ─────────────────────────────────
|
|
|
|
it("handles filename with multiple dots", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "my.app.logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const manifest = JSON.parse(zip.getEntry("manifest.json")?.getData().toString("utf-8"));
|
|
expect(manifest.name).toBe("my.app.logo");
|
|
});
|
|
|
|
// ── TIFF input format ─────────────────────────────────────────────
|
|
|
|
it("generates favicons from TIFF input", async () => {
|
|
const TIFF = readFileSync(join(FIXTURES, "formats", "sample.tiff"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.tiff", contentType: "image/tiff", content: TIFF },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
});
|
|
|
|
// ── AVIF input format ───────────────────────────────────────────
|
|
|
|
it("generates favicons from AVIF input", async () => {
|
|
const AVIF = readFileSync(join(FIXTURES, "formats", "sample.avif"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.avif", contentType: "image/avif", content: AVIF },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("android-chrome-192x192.png");
|
|
});
|
|
|
|
// ── Verify ICO dimensions ───────────────────────────────────────
|
|
|
|
it("favicon.ico has correct dimensions", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const icoEntry = zip.getEntry("favicon.ico");
|
|
expect(icoEntry).toBeDefined();
|
|
const icoData = icoEntry!.getData();
|
|
expect(icoData.length).toBeGreaterThan(0);
|
|
|
|
if (HAS_MAGICK) {
|
|
// Real ICO header
|
|
expect(icoData[0]).toBe(0x00);
|
|
expect(icoData[2]).toBe(0x01);
|
|
} else {
|
|
// Fallback: PNG wrapped as .ico
|
|
const meta = await sharp(icoData).metadata();
|
|
expect(meta.width).toBe(32);
|
|
expect(meta.height).toBe(32);
|
|
expect(meta.format).toBe("png");
|
|
}
|
|
});
|
|
|
|
// ── Apple touch icon is 180x180 ─────────────────────────────────
|
|
|
|
it("apple-touch-icon.png has correct 180x180 dimensions", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entry = zip.getEntry("apple-touch-icon.png");
|
|
expect(entry).toBeDefined();
|
|
const meta = await sharp(entry?.getData()).metadata();
|
|
expect(meta.width).toBe(180);
|
|
expect(meta.height).toBe(180);
|
|
});
|
|
|
|
// ── Animated GIF input ────────────────────────────────────────────
|
|
|
|
it("generates favicons from animated GIF input", async () => {
|
|
const GIF = readFileSync(join(FIXTURES, "animated.gif"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "anim.gif", contentType: "image/gif", content: GIF },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
});
|
|
|
|
// ── Batch: 5+ images ────────────────────────────────────────────
|
|
|
|
it("generates favicons for 5 images in subfolders", async () => {
|
|
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
|
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
|
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "file", filename: "c.webp", contentType: "image/webp", content: WEBP },
|
|
{ name: "file", filename: "d.png", contentType: "image/png", content: TINY },
|
|
{ name: "file", filename: "e.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries.some((e) => e.startsWith("a/"))).toBe(true);
|
|
expect(entries.some((e) => e.startsWith("b/"))).toBe(true);
|
|
expect(entries.some((e) => e.startsWith("c/"))).toBe(true);
|
|
expect(entries.some((e) => e.startsWith("d/"))).toBe(true);
|
|
expect(entries.some((e) => e.startsWith("e/"))).toBe(true);
|
|
});
|
|
|
|
// ── HEIF format input ────────────────────────────────────────────
|
|
|
|
it("generates favicons from HEIF input", { timeout: 120_000 }, async () => {
|
|
const HEIF = readFileSync(join(FIXTURES, "content", "motorcycle.heif"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "photo.heif", contentType: "image/heif", content: HEIF },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
});
|
|
|
|
// ── All favicon PNG sizes are valid images ──────────────────────
|
|
|
|
it("all generated PNG files are valid images with non-zero data", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
|
|
const pngEntries = [
|
|
"favicon-16x16.png",
|
|
"favicon-32x32.png",
|
|
"favicon-48x48.png",
|
|
"apple-touch-icon.png",
|
|
"android-chrome-192x192.png",
|
|
"android-chrome-512x512.png",
|
|
];
|
|
|
|
for (const name of pngEntries) {
|
|
const entry = zip.getEntry(name);
|
|
expect(entry).toBeDefined();
|
|
const data = entry?.getData();
|
|
expect(data.length).toBeGreaterThan(0);
|
|
const meta = await sharp(data).metadata();
|
|
expect(meta.format).toBe("png");
|
|
}
|
|
});
|
|
|
|
// ── Multi-size ICO (magick-gated) ──────────────────────────────
|
|
|
|
it("favicon.ico is a real multi-size ICO when magick is available", async () => {
|
|
if (!HAS_MAGICK) {
|
|
// Skip the ICO header assertion when magick is unavailable;
|
|
// the fallback produces a PNG-renamed-as-ICO which is still functional.
|
|
return;
|
|
}
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const icoEntry = zip.getEntry("favicon.ico");
|
|
expect(icoEntry).toBeDefined();
|
|
const icoData = icoEntry!.getData();
|
|
|
|
// Real ICO header: bytes 0-1 = 0x0000 (reserved), bytes 2-3 = 0x0100 (type 1 = ICO)
|
|
expect(icoData[0]).toBe(0x00);
|
|
expect(icoData[1]).toBe(0x00);
|
|
expect(icoData[2]).toBe(0x01);
|
|
expect(icoData[3]).toBe(0x00);
|
|
|
|
// Image count at offset 4-5 (little-endian); should be >= 4 (16/32/48/256)
|
|
const imageCount = icoData[4] | (icoData[5] << 8);
|
|
expect(imageCount).toBeGreaterThanOrEqual(4);
|
|
});
|
|
|
|
// ── ICO output has valid content ────────────────────────────────
|
|
|
|
it("ICO output has valid content", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const icoEntry = zip.getEntry("favicon.ico");
|
|
expect(icoEntry).toBeDefined();
|
|
|
|
const icoData = icoEntry!.getData();
|
|
expect(icoData.length).toBeGreaterThan(0);
|
|
|
|
if (HAS_MAGICK) {
|
|
// Real ICO: header check
|
|
expect(icoData[0]).toBe(0x00);
|
|
expect(icoData[2]).toBe(0x01);
|
|
} else {
|
|
// Fallback: PNG wrapped, so Sharp can read it
|
|
const meta = await sharp(icoData).metadata();
|
|
expect(meta.width).toBe(32);
|
|
expect(meta.height).toBe(32);
|
|
}
|
|
});
|
|
|
|
// ── Favicon from stress-large.jpg verifies 512x512 is square ───
|
|
|
|
it("512x512 chrome icon is square from large rectangular input", async () => {
|
|
const LARGE = readFileSync(join(FIXTURES, "content", "stress-large.jpg"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "large.jpg", contentType: "image/jpeg", content: LARGE },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entry512 = zip.getEntry("android-chrome-512x512.png");
|
|
expect(entry512).toBeDefined();
|
|
const meta = await sharp(entry512?.getData()).metadata();
|
|
expect(meta.width).toBe(512);
|
|
expect(meta.height).toBe(512);
|
|
});
|
|
|
|
// ── GIF with settings combination ───────────────────────────────
|
|
|
|
it("generates favicons from GIF with empty settings", async () => {
|
|
const GIF = readFileSync(join(FIXTURES, "animated.gif"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "anim.gif", contentType: "image/gif", content: GIF },
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("manifest.json");
|
|
expect(entries).toContain("favicon-snippet.html");
|
|
});
|
|
|
|
// ── Manifest icons array has correct structure ────────────────
|
|
|
|
it("manifest icons have correct src, sizes, and type fields", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const manifest = JSON.parse(zip.getEntry("manifest.json")?.getData().toString("utf-8"));
|
|
expect(manifest.icons).toHaveLength(2);
|
|
for (const icon of manifest.icons) {
|
|
expect(icon.src).toBeDefined();
|
|
expect(icon.sizes).toBeDefined();
|
|
expect(icon.type).toBe("image/png");
|
|
}
|
|
expect(manifest.background_color).toBe("#ffffff");
|
|
});
|
|
|
|
// ── HTML snippet contains all expected link tags ──────────────
|
|
|
|
it("HTML snippet contains 16, 32, 48, apple-touch, and manifest links", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const html = zip.getEntry("favicon-snippet.html")?.getData().toString("utf-8");
|
|
expect(html).toContain("favicon-16x16.png");
|
|
expect(html).toContain("favicon-32x32.png");
|
|
expect(html).toContain("favicon-48x48.png");
|
|
expect(html).toContain("apple-touch-icon.png");
|
|
expect(html).toContain("manifest.json");
|
|
});
|
|
|
|
// ── Total ZIP size is positive ────────────────────────────────
|
|
|
|
it("generated ZIP has positive total size", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const zipBuffer = Buffer.from(res.rawPayload);
|
|
expect(zipBuffer.length).toBeGreaterThan(0);
|
|
const zip = new AdmZip(zipBuffer);
|
|
const totalSize = zip.getEntries().reduce((s, e) => s + (e.header.size || 0), 0);
|
|
expect(totalSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Settings: radius + padding ──────────────────────────────────
|
|
|
|
it("accepts radius and padding settings", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
{ name: "settings", content: JSON.stringify({ radius: 50, padding: 10 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("application/zip");
|
|
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("favicon.ico");
|
|
expect(entries).toContain("manifest.json");
|
|
|
|
// Verify the 32x32 icon is still exactly 32x32 after padding/radius
|
|
const entry32 = zip.getEntry("favicon-32x32.png");
|
|
expect(entry32).toBeDefined();
|
|
const meta = await sharp(entry32?.getData()).metadata();
|
|
expect(meta.width).toBe(32);
|
|
expect(meta.height).toBe(32);
|
|
});
|
|
|
|
// ── Settings: full combo with size filter + themeColor ──────────
|
|
|
|
it("respects sizes filter and themeColor in manifest", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
background: "#ff0000",
|
|
padding: 20,
|
|
radius: 25,
|
|
sizes: [16, 32, 512],
|
|
themeColor: "#336699",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/favicon",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("application/zip");
|
|
|
|
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
|
const entries = zip.getEntries().map((e) => e.entryName);
|
|
|
|
// Only selected PNG sizes emitted
|
|
expect(entries).toContain("favicon-16x16.png");
|
|
expect(entries).toContain("favicon-32x32.png");
|
|
expect(entries).toContain("android-chrome-512x512.png");
|
|
expect(entries).not.toContain("favicon-48x48.png");
|
|
expect(entries).not.toContain("apple-touch-icon.png");
|
|
expect(entries).not.toContain("android-chrome-192x192.png");
|
|
|
|
// ICO, manifest, snippet always present
|
|
expect(entries).toContain("favicon.ico");
|
|
expect(entries).toContain("manifest.json");
|
|
expect(entries).toContain("favicon-snippet.html");
|
|
|
|
// themeColor reflected in manifest
|
|
const manifest = JSON.parse(zip.getEntry("manifest.json")?.getData().toString("utf-8"));
|
|
expect(manifest.theme_color).toBe("#336699");
|
|
expect(manifest.background_color).toBe("#336699");
|
|
});
|
|
});
|