mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 5b - ai pool: ocr-pdf, transcription, background composites (5 tools) (#226)
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Integration tests for the auto-subtitles tool (/api/v1/tools/auto-subtitles).
|
||||
*
|
||||
* The transcription bundle (faster-whisper) is not installed locally, so the
|
||||
* 501 gate is always hit. Validation paths (bad settings) fire after the 501
|
||||
* check. The bundle-gated happy path lives in a skipped describe for
|
||||
* in-container-after-install runs.
|
||||
*/
|
||||
|
||||
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 MEDIA = join(FIXTURES, "media");
|
||||
const MP4 = readFileSync(join(MEDIA, "tiny.mp4"));
|
||||
|
||||
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("auto-subtitles", () => {
|
||||
// -- 501 gate (always fires locally: bundle never installed) --
|
||||
|
||||
it("returns 501 FEATURE_NOT_INSTALLED when bundle is absent", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp4",
|
||||
contentType: "video/mp4",
|
||||
content: MP4,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/auto-subtitles",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("transcription");
|
||||
expect(json.featureName).toBe("Transcription");
|
||||
expect(json.estimatedSize).toBeDefined();
|
||||
});
|
||||
|
||||
// -- Validation (501 fires before settings parse, so these also 501) --
|
||||
|
||||
it("returns 501 even with invalid format (gate fires first)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp4",
|
||||
contentType: "video/mp4",
|
||||
content: MP4,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: "ass" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/auto-subtitles",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// 501 because the bundle gate fires before settings validation
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests (401)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp4",
|
||||
contentType: "video/mp4",
|
||||
content: MP4,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/auto-subtitles",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
// -- Bundle-gated happy path (skipped locally, runs after bundle install) --
|
||||
|
||||
// The transcription bundle is ~600 MB and only available in Docker.
|
||||
// These tests run when the bundle is installed (e.g., during Task 7 smoke).
|
||||
// Locally they always skip.
|
||||
describe.skip("with transcription bundle installed", () => {
|
||||
it("generates subtitles from video (202 + async)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp4",
|
||||
contentType: "video/mp4",
|
||||
content: MP4,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: "srt" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/auto-subtitles",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
expect(json.async).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("generates VTT subtitles from video", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp4",
|
||||
contentType: "video/mp4",
|
||||
content: MP4,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: "vtt" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/auto-subtitles",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
// Full VTT structure validation (WEBVTT header, dot timestamps)
|
||||
// happens after polling completes in the Task 7 smoke.
|
||||
}, 120_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Integration tests for the background-replace tool (/api/v1/tools/background-replace).
|
||||
*
|
||||
* This tool reuses the rembg bundle (background-removal). The 501 gate fires
|
||||
* before any settings validation when the bundle is absent. Locally and in CI,
|
||||
* the bundle is never installed, so the 501 surface is always exercised.
|
||||
*
|
||||
* The compositeOnColor helper has dedicated unit coverage in
|
||||
* tests/unit/api/background-composite.test.ts. The rembg model never runs
|
||||
* locally; bundle-gated happy paths are in a skipped describe.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("background-replace", () => {
|
||||
// -- 501 gate (always fires locally: background-removal bundle never installed) --
|
||||
|
||||
it("returns 501 FEATURE_NOT_INSTALLED when bundle is absent", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/background-replace",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("background-removal");
|
||||
expect(json.featureName).toBe("Background Removal");
|
||||
expect(json.estimatedSize).toBeDefined();
|
||||
});
|
||||
|
||||
// -- Auth gate --
|
||||
|
||||
it("rejects unauthenticated requests (401)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/background-replace",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
// -- Validation: 501 fires before settings parse, so bad hex also 501s --
|
||||
|
||||
it("returns 501 even with invalid color hex (gate fires first)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ color: "not-a-hex" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/background-replace",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// 501 because the bundle gate fires before settings validation
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
// -- Bundle-gated happy path (skipped: background-removal bundle is 4-5 GB) --
|
||||
|
||||
// The background-removal bundle is not installed in any verification environment.
|
||||
// These tests exist for future in-container runs after bundle install.
|
||||
// The 501 contract + compositeOnColor unit tests carry the verification.
|
||||
describe.skip("with background-removal bundle installed", () => {
|
||||
it("replaces background with color (202 + async)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ color: "#ff0000" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/background-replace",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
expect(json.async).toBe(true);
|
||||
}, 300_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Integration tests for the blur-background tool (/api/v1/tools/blur-background).
|
||||
*
|
||||
* This tool reuses the rembg bundle (background-removal). The 501 gate fires
|
||||
* before any settings validation when the bundle is absent. Locally and in CI,
|
||||
* the bundle is never installed, so the 501 surface is always exercised.
|
||||
*
|
||||
* The blurBackground helper has dedicated unit coverage in
|
||||
* tests/unit/api/background-composite.test.ts. The rembg model never runs
|
||||
* locally; bundle-gated happy paths are in a skipped describe.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("blur-background", () => {
|
||||
// -- 501 gate (always fires locally: background-removal bundle never installed) --
|
||||
|
||||
it("returns 501 FEATURE_NOT_INSTALLED when bundle is absent", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/blur-background",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("background-removal");
|
||||
expect(json.featureName).toBe("Background Removal");
|
||||
expect(json.estimatedSize).toBeDefined();
|
||||
});
|
||||
|
||||
// -- Auth gate --
|
||||
|
||||
it("rejects unauthenticated requests (401)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/blur-background",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
// -- Validation: 501 fires before settings parse, so intensity=0 also 501s --
|
||||
|
||||
it("returns 501 even with invalid intensity (gate fires first)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ intensity: 0 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/blur-background",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// 501 because the bundle gate fires before settings validation
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
// -- Bundle-gated happy path (skipped: background-removal bundle is 4-5 GB) --
|
||||
|
||||
// The background-removal bundle is not installed in any verification environment.
|
||||
// These tests exist for future in-container runs after bundle install.
|
||||
// The 501 contract + blurBackground unit tests carry the verification.
|
||||
describe.skip("with background-removal bundle installed", () => {
|
||||
it("blurs background with default intensity (202 + async)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ intensity: 75 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/blur-background",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
expect(json.async).toBe(true);
|
||||
}, 300_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Integration tests for the ocr-pdf tool (/api/v1/tools/ocr-pdf).
|
||||
*
|
||||
* The OCR bundle (PaddleOCR / Tesseract) is 3-4 GB and not installed locally
|
||||
* or in any verification environment this wave. The 501 gate is always hit.
|
||||
* The bundle-gated happy path lives in a skipped describe for future
|
||||
* in-container-after-install runs. The 501 contract + python-side conventions
|
||||
* carry the verification.
|
||||
*/
|
||||
|
||||
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 PDF = readFileSync(join(FIXTURES, "test-3page.pdf"));
|
||||
|
||||
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("ocr-pdf", () => {
|
||||
// -- 501 gate (always fires locally: OCR bundle never installed) --
|
||||
|
||||
it("returns 501 FEATURE_NOT_INSTALLED when bundle is absent", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/ocr-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("ocr");
|
||||
expect(json.featureName).toBe("OCR");
|
||||
expect(json.estimatedSize).toBeDefined();
|
||||
});
|
||||
|
||||
// -- Auth gate --
|
||||
|
||||
it("rejects unauthenticated requests (401)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/ocr-pdf",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
// -- Validation (501 fires before settings parse, so bad quality also 501s) --
|
||||
|
||||
it("returns 501 even with invalid quality (gate fires first)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ quality: "ultra" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/ocr-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// 501 because the bundle gate fires before settings validation
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
// -- Bundle-gated happy path (skipped: OCR bundle is 3-4 GB) --
|
||||
|
||||
// The OCR bundle is not installed in any verification environment this wave.
|
||||
// These tests exist for future in-container runs after bundle install.
|
||||
// The 501 contract + python-side conventions carry the verification.
|
||||
describe.skip("with ocr bundle installed", () => {
|
||||
it("extracts text from PDF (202 + async)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "test-3page.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: PDF,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ quality: "fast", pages: "1" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/ocr-pdf",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
expect(json.async).toBe(true);
|
||||
}, 300_000);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,11 @@ import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
* URLs (consolidated into adjust-colors).
|
||||
*/
|
||||
const REGISTRY_EXEMPT = new Set([
|
||||
"auto-subtitles",
|
||||
"background-replace",
|
||||
"barcode-generate",
|
||||
"barcode-read",
|
||||
"blur-background",
|
||||
"bulk-rename",
|
||||
"collage",
|
||||
"color-palette",
|
||||
@@ -30,10 +33,12 @@ const REGISTRY_EXEMPT = new Set([
|
||||
"image-to-pdf",
|
||||
"info",
|
||||
"ocr",
|
||||
"ocr-pdf",
|
||||
"pdf-to-image",
|
||||
"qr-generate",
|
||||
"stitch",
|
||||
"svg-to-raster",
|
||||
"transcribe-audio",
|
||||
"watermark-image",
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Integration tests for the transcribe-audio tool (/api/v1/tools/transcribe-audio).
|
||||
*
|
||||
* The transcription bundle (faster-whisper) is not installed locally, so the
|
||||
* 501 gate is always hit. Validation paths (bad settings) are tested after
|
||||
* the 501 check fires first. The bundle-gated happy path lives in a skipped
|
||||
* describe for in-container-after-install runs.
|
||||
*/
|
||||
|
||||
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 MEDIA = join(FIXTURES, "media");
|
||||
const MP3 = readFileSync(join(MEDIA, "tiny.mp3"));
|
||||
|
||||
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("transcribe-audio", () => {
|
||||
// -- 501 gate (always fires locally: bundle never installed) --
|
||||
|
||||
it("returns 501 FEATURE_NOT_INSTALLED when bundle is absent", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
content: MP3,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/transcribe-audio",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("transcription");
|
||||
expect(json.featureName).toBe("Transcription");
|
||||
expect(json.estimatedSize).toBeDefined();
|
||||
});
|
||||
|
||||
// -- Validation (501 fires before settings parse, so these also 501) --
|
||||
|
||||
it("returns 501 even with invalid outputFormat (gate fires first)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
content: MP3,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ outputFormat: "doc" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/transcribe-audio",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// 501 because the bundle gate fires before settings validation
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests (401)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
content: MP3,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/transcribe-audio",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
// -- Bundle-gated happy path (skipped locally, runs after bundle install) --
|
||||
|
||||
// The transcription bundle is ~600 MB and only available in Docker.
|
||||
// These tests run when the bundle is installed (e.g., during Task 7 smoke).
|
||||
// Locally they always skip.
|
||||
describe.skip("with transcription bundle installed", () => {
|
||||
it("transcribes audio to txt (202 + async)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
content: MP3,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ outputFormat: "txt" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/transcribe-audio",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
expect(json.async).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("transcribes audio to srt with correct structure", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "tiny.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
content: MP3,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ outputFormat: "srt" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/transcribe-audio",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.jobId).toBeDefined();
|
||||
// Full SRT structure validation happens after polling completes.
|
||||
// The sine tone fixture may produce empty or noise text;
|
||||
// we assert mechanics (counter line "1", arrow timestamp), not words.
|
||||
}, 120_000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user