mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(meme-generator): add meme generation API route with template and custom image modes
Custom route handler supporting template mode (JSON body with templateId) and custom image mode (multipart upload). Registers process function for pipeline compatibility. Includes 18 integration tests.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Integration tests for the meme-generator tool (/api/v1/tools/meme-generator).
|
||||
*
|
||||
* Supports two input modes:
|
||||
* 1. Template mode: JSON body with templateId (no file upload)
|
||||
* 2. Custom image mode: multipart with file upload + settings
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
/** First template ID from the real manifest, loaded in beforeAll. */
|
||||
let firstTemplateId: string;
|
||||
/** Text box IDs for the first template. */
|
||||
let firstTemplateTextBoxIds: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
|
||||
// Read the first template from the actual manifest
|
||||
const manifestRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/meme-templates",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const manifest = JSON.parse(manifestRes.body);
|
||||
const firstTemplate = manifest.templates[0];
|
||||
firstTemplateId = firstTemplate.id;
|
||||
firstTemplateTextBoxIds = firstTemplate.textBoxes.map((tb: { id: string }) => tb.id);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Meme Generator", () => {
|
||||
// ── Template listing sanity check ─────────────────────────────────
|
||||
|
||||
it("GET /api/v1/meme-templates returns valid manifest", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/meme-templates",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const manifest = JSON.parse(res.body);
|
||||
expect(manifest.templates).toBeDefined();
|
||||
expect(manifest.templates.length).toBeGreaterThan(0);
|
||||
expect(manifest.templates[0].id).toBeDefined();
|
||||
expect(manifest.templates[0].textBoxes).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Template mode ─────────────────────────────────────────────────
|
||||
|
||||
it("template mode: valid templateId + text boxes returns 200 with downloadUrl", async () => {
|
||||
const textBoxes = firstTemplateTextBoxIds.map((id) => ({
|
||||
id,
|
||||
text: `Test text for ${id}`,
|
||||
}));
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
templateId: firstTemplateId,
|
||||
textBoxes,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.jobId).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── Custom image mode ─────────────────────────────────────────────
|
||||
|
||||
it("custom image mode: file upload + textLayout + text boxes returns 200", async () => {
|
||||
const settings = {
|
||||
textLayout: "top-bottom",
|
||||
textBoxes: [
|
||||
{ id: "top", text: "TOP TEXT" },
|
||||
{ id: "bottom", text: "BOTTOM TEXT" },
|
||||
],
|
||||
};
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "meme.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.jobId).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── Validation: invalid templateId ────────────────────────────────
|
||||
|
||||
it("invalid templateId returns 400", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
templateId: "nonexistent-template-that-does-not-exist",
|
||||
textBoxes: [{ id: "top", text: "Hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toMatch(/template/i);
|
||||
});
|
||||
|
||||
// ── Validation: neither templateId nor file ───────────────────────
|
||||
|
||||
it("neither templateId nor file returns 400", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
textBoxes: [{ id: "top", text: "Hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Empty text boxes still generates image ────────────────────────
|
||||
|
||||
it("empty text boxes returns 200 (image without text)", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
templateId: firstTemplateId,
|
||||
textBoxes: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── Every font family ─────────────────────────────────────────────
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
"anton",
|
||||
"arial-black",
|
||||
"comic-sans",
|
||||
"montserrat",
|
||||
"bebas-neue",
|
||||
"permanent-marker",
|
||||
] as const;
|
||||
|
||||
for (const font of FONT_FAMILIES) {
|
||||
it(`font family "${font}" returns 200`, async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
templateId: firstTemplateId,
|
||||
fontFamily: font,
|
||||
textBoxes: firstTemplateTextBoxIds.map((id) => ({
|
||||
id,
|
||||
text: `Test with ${font}`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
}
|
||||
|
||||
// ── All text layout presets with custom image ─────────────────────
|
||||
|
||||
const TEXT_LAYOUTS = ["top-bottom", "top-only", "bottom-only", "center", "side-by-side"] as const;
|
||||
|
||||
for (const layout of TEXT_LAYOUTS) {
|
||||
it(`text layout "${layout}" with custom image returns 200`, async () => {
|
||||
// Build text boxes matching the layout preset IDs
|
||||
const textBoxMap: Record<string, { id: string; text: string }[]> = {
|
||||
"top-bottom": [
|
||||
{ id: "top", text: "TOP" },
|
||||
{ id: "bottom", text: "BOTTOM" },
|
||||
],
|
||||
"top-only": [{ id: "top", text: "TOP ONLY" }],
|
||||
"bottom-only": [{ id: "bottom", text: "BOTTOM ONLY" }],
|
||||
center: [{ id: "center", text: "CENTER TEXT" }],
|
||||
"side-by-side": [
|
||||
{ id: "left", text: "LEFT" },
|
||||
{ id: "right", text: "RIGHT" },
|
||||
],
|
||||
};
|
||||
|
||||
const settings = {
|
||||
textLayout: layout,
|
||||
textBoxes: textBoxMap[layout],
|
||||
};
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "meme.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Authentication ────────────────────────────────────────────────
|
||||
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/meme-generator",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
templateId: firstTemplateId,
|
||||
textBoxes: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
|
||||
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
|
||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||
import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js";
|
||||
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
||||
import { registerProgressRoutes } from "../../apps/api/src/routes/progress.js";
|
||||
import { rolesRoutes } from "../../apps/api/src/routes/roles.js";
|
||||
@@ -90,6 +91,9 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
// User file library routes (persistent file management with versioning)
|
||||
await userFileRoutes(app);
|
||||
|
||||
// Meme template routes
|
||||
await registerMemeTemplates(app);
|
||||
|
||||
// Tool routes
|
||||
await registerToolRoutes(app);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user