mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
The 10000x10000 QR code generation exceeds the default 30s timeout on GitHub Actions runners. Bump to 120s to accommodate slower CI hardware.
695 lines
19 KiB
TypeScript
695 lines
19 KiB
TypeScript
/**
|
|
* Integration tests for the qr-generate tool (/api/v1/tools/qr-generate).
|
|
*
|
|
* Covers QR code generation from text/URL input, custom size and colors,
|
|
* error correction levels, download verification, and input validation.
|
|
*/
|
|
|
|
import { join } from "node:path";
|
|
import sharp from "sharp";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
|
|
|
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("QR Generate", () => {
|
|
it("generates a QR code from a URL string", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "https://example.com",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
expect(result.jobId).toBeDefined();
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("generates a downloadable PNG image", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "Hello World",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
// Download the QR code image
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
});
|
|
|
|
expect(dlRes.statusCode).toBe(200);
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.format).toBe("png");
|
|
expect(meta.width).toBeGreaterThan(0);
|
|
expect(meta.height).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("respects the custom size parameter", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "test",
|
|
size: 800,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
});
|
|
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.width).toBe(800);
|
|
expect(meta.height).toBe(800);
|
|
});
|
|
|
|
it("uses default size of 400 when not specified", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "default size test",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
});
|
|
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.width).toBe(400);
|
|
expect(meta.height).toBe(400);
|
|
});
|
|
|
|
it("accepts custom foreground and background colors", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "colored QR",
|
|
foreground: "#FF0000",
|
|
background: "#00FF00",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("accepts all error correction levels", async () => {
|
|
for (const level of ["L", "M", "Q", "H"]) {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: `EC level ${level}`,
|
|
errorCorrection: level,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
});
|
|
|
|
it("returns all expected fields in the response", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "structure test",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
expect(result).toHaveProperty("jobId");
|
|
expect(result).toHaveProperty("downloadUrl");
|
|
expect(result).toHaveProperty("originalSize");
|
|
expect(result).toHaveProperty("processedSize");
|
|
expect(result.originalSize).toBe(0);
|
|
});
|
|
|
|
// ── Validation ──────────────────────────────────────────────────────
|
|
|
|
it("rejects requests without text", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/invalid settings/i);
|
|
});
|
|
|
|
it("rejects empty text", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: { text: "" },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects invalid color format", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "test",
|
|
foreground: "red",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/invalid settings/i);
|
|
});
|
|
|
|
it("rejects size below minimum", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "test",
|
|
size: 50,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects size above maximum", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "test",
|
|
size: 20000,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects invalid error correction level", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "test",
|
|
errorCorrection: "X",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects unauthenticated requests", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: { "content-type": "application/json" },
|
|
payload: { text: "test" },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
// ── Data type variations ──────────────────────────────────────
|
|
|
|
it("generates QR code for a long URL", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "https://example.com/very/long/path/with/many/segments?param1=value1¶m2=value2¶m3=value3",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("generates QR code for multiline text", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "Line 1\nLine 2\nLine 3",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("generates QR code for special characters", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "Hello! @#$%^&*() <> {}",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
// ── Size variations ───────────────────────────────────────────
|
|
|
|
it("generates minimum allowed size QR code", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "min",
|
|
size: 100,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
});
|
|
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.width).toBe(100);
|
|
});
|
|
|
|
// ── Color combinations ────────────────────────────────────────
|
|
|
|
it("generates QR with white foreground on black background", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "inverted",
|
|
foreground: "#FFFFFF",
|
|
background: "#000000",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("handles very long text (approaching max 2000 chars)", async () => {
|
|
const longText = "A".repeat(2000);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: longText,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("rejects text exceeding max length (2001 chars)", async () => {
|
|
const tooLong = "A".repeat(2001);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: tooLong,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("generates QR code at large size (2000)", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "large size test",
|
|
size: 2000,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("generates QR with transparent background (3-char hex)", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "short hex",
|
|
foreground: "#333",
|
|
background: "#FFF",
|
|
},
|
|
});
|
|
|
|
// 3-char hex might not be accepted depending on validation
|
|
// Just verify a consistent response
|
|
expect(res.statusCode === 200 || res.statusCode === 400).toBe(true);
|
|
});
|
|
|
|
// ── Error correction level impact ─────────────────────────────
|
|
|
|
it("higher error correction produces equal or larger QR", async () => {
|
|
const resL = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "error correction comparison",
|
|
errorCorrection: "L",
|
|
size: 400,
|
|
},
|
|
});
|
|
|
|
const resH = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "error correction comparison",
|
|
errorCorrection: "H",
|
|
size: 400,
|
|
},
|
|
});
|
|
|
|
expect(resL.statusCode).toBe(200);
|
|
expect(resH.statusCode).toBe(200);
|
|
// Both should produce valid QR codes of the same pixel size
|
|
const resultL = JSON.parse(resL.body);
|
|
const resultH = JSON.parse(resH.body);
|
|
expect(resultL.processedSize).toBeGreaterThan(0);
|
|
expect(resultH.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Unicode text ───────────────────────────────────────────────
|
|
|
|
it("generates QR code for Unicode text", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "Bonjour le monde! Hola mundo!",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
// ── Single character ───────────────────────────────────────────
|
|
|
|
it("generates QR code for a single character", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "X",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Same foreground and background ─────────────────────────────
|
|
|
|
it("generates QR code with identical foreground and background colors", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "same colors",
|
|
foreground: "#AABBCC",
|
|
background: "#AABBCC",
|
|
},
|
|
});
|
|
|
|
// Should still succeed even though QR is unreadable
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
// ── Max allowed size ───────────────────────────────────────────
|
|
|
|
it("generates QR code at maximum allowed size (10000)", { timeout: 120_000 }, async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "max size",
|
|
size: 10000,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Lowercase hex colors ───────────────────────────────────────
|
|
|
|
it("accepts lowercase hex colors", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "lowercase hex",
|
|
foreground: "#abcdef",
|
|
background: "#fedcba",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
// ── Mixed case hex colors ──────────────────────────────────────
|
|
|
|
it("accepts mixed case hex colors", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "mixed hex",
|
|
foreground: "#AbCdEf",
|
|
background: "#fEdCbA",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
// ── Download verifies correct size ─────────────────────────────
|
|
|
|
it("downloaded QR code has the exact requested size", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "size verify",
|
|
size: 256,
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
});
|
|
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.width).toBe(256);
|
|
expect(meta.height).toBe(256);
|
|
});
|
|
|
|
// ── Invalid background color format ────────────────────────────
|
|
|
|
it("rejects invalid background color format", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/qr-generate",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
payload: {
|
|
text: "test",
|
|
background: "blue",
|
|
},
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|