mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-test+suite-overhaul-and-real-fixtures' into chore/consolidate-v2.0.0
# Conflicts: # tests/integration/generated/settings-matrix.test.ts # tests/integration/platform/api.test.ts # tests/integration/platform/concurrent.test.ts # tests/integration/platform/factory-multi-input.test.ts # tests/integration/security/adversarial-comprehensive.test.ts # tests/integration/security/adversarial-coverage-gaps.test.ts # tests/integration/security/adversarial-extended.test.ts # tests/integration/security/adversarial-final-gaps.test.ts # tests/integration/security/adversarial-matrix.test.ts # tests/integration/security/adversarial-security.test.ts # tests/integration/security/adversarial.test.ts # tests/integration/tools/image/color-adjustments.test.ts
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const STEREO = readFixture(fixtures.audio.stereo);
|
||||
const MONO_MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
function postTool(
|
||||
filename: string,
|
||||
contentTypeHeader: string,
|
||||
fileContent: Buffer,
|
||||
settings: Record<string, unknown>,
|
||||
) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename, contentType: contentTypeHeader, content: fileContent },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/audio-channels",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("audio-channels (requires ffmpeg)", () => {
|
||||
it("stereo-to-mono on stereo file produces mono output", async () => {
|
||||
const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, {
|
||||
mode: "stereo-to-mono",
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "ch-test-"));
|
||||
const probeFile = join(tmpDir, "mono.wav");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio?.channels).toBe(1);
|
||||
}, 60_000);
|
||||
|
||||
it("swap on stereo file returns 200 with 2 channels", async () => {
|
||||
const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, { mode: "swap" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "ch-test-"));
|
||||
const probeFile = join(tmpDir, "swapped.wav");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio?.channels).toBe(2);
|
||||
}, 60_000);
|
||||
|
||||
it("stereo-to-mono on mono input returns 422", async () => {
|
||||
const res = await postTool("tiny.mp3", "audio/mpeg", MONO_MP3, {
|
||||
mode: "stereo-to-mono",
|
||||
});
|
||||
expect(res.statusCode).toBe(422);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.details).toMatch(/stereo input/i);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects missing mode (400)", async () => {
|
||||
const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, {});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/audio-metadata",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("audio-metadata (requires ffmpeg)", () => {
|
||||
it("sets title tag and envelope contains metadata with tags object", async () => {
|
||||
const res = await runTool({ title: "EnvelopeTitle2026" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
// Envelope carries resultPayload.metadata at top level (sync passthrough)
|
||||
expect(envelope.metadata).toBeDefined();
|
||||
expect(typeof envelope.metadata.tags).toBe("object");
|
||||
|
||||
// Download and probe the output to verify the tag was written
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "audio-meta-"));
|
||||
const probeFile = join(tmpDir, "tagged.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.tags?.title).toBe("EnvelopeTitle2026");
|
||||
}, 60_000);
|
||||
|
||||
it("strips metadata with strip true and returns 200", async () => {
|
||||
const res = await runTool({ strip: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
}, 60_000);
|
||||
|
||||
it("rejects title exceeding 500 characters (400)", async () => {
|
||||
const res = await runTool({ title: "x".repeat(501) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/audio-speed",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("audio-speed (requires ffmpeg)", () => {
|
||||
it("doubles speed and halves duration", async () => {
|
||||
const res = await runTool({ factor: 2 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "aspeed-test-"));
|
||||
const probeFile = join(tmpDir, "sped.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// tiny.mp3 is ~1.044s; at 2x speed output should be ~0.522s (within 25%)
|
||||
const expected = 1.044 / 2;
|
||||
expect(info.durationS).toBeGreaterThan(expected * 0.75);
|
||||
expect(info.durationS).toBeLessThan(expected * 1.25);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects factor out of range", async () => {
|
||||
const res = await runTool({ factor: 10 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const WAV = readFixture(fixtures.audio.tiny("wav"));
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>, file = WAV, filename = "tiny.wav") {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename, contentType: "audio/wav", content: file },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/convert-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("convert-audio (requires ffmpeg)", () => {
|
||||
it("converts wav to mp3 and returns 200", async () => {
|
||||
const res = await runTool({ format: "mp3" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
const outName = envelope.downloadUrl.split("/").pop() as string;
|
||||
expect(outName.endsWith(".mp3")).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it("converts mp3 to ogg and returns 200", async () => {
|
||||
// mp3 fixture (44100 Hz); the 8 kHz wav -> ogg case is the regression test below.
|
||||
const res = await runTool({ format: "ogg" }, MP3, "tiny.mp3");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
const outName = envelope.downloadUrl.split("/").pop() as string;
|
||||
expect(outName.endsWith(".ogg")).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it("converts 8 kHz wav to ogg (regression: libvorbis low samplerate)", async () => {
|
||||
// tiny.wav is 8 kHz; a fixed bitrate (-b:a) made libvorbis "encoder setup failed".
|
||||
// The ogg path now uses -q:a (quality VBR), which adapts to the sample rate.
|
||||
const res = await runTool({ format: "ogg" }, WAV, "tiny.wav");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
expect((envelope.downloadUrl.split("/").pop() as string).endsWith(".ogg")).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/fade-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("fade-audio (requires ffmpeg)", () => {
|
||||
it("applies default fades and returns 200", async () => {
|
||||
const res = await runTool({ fadeInS: 1, fadeOutS: 1 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects when both fades are 0", async () => {
|
||||
const res = await runTool({ fadeInS: 0, fadeOutS: 0 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
const WAV = readFixture(fixtures.audio.tiny("wav"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("merge-audio (requires ffmpeg)", () => {
|
||||
it("merges two audio files with different sample rates", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "file", filename: "tiny.wav", contentType: "audio/wav", content: WAV },
|
||||
{ name: "settings", content: JSON.stringify({ format: "mp3" }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/merge-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
// Probe: duration should be close to 1.044 + 1.0 = 2.044s
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "merge-audio-test-"));
|
||||
const probeFile = join(tmpDir, "merged.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const expected = 2.04;
|
||||
expect(info.durationS).toBeDefined();
|
||||
expect(Math.abs((info.durationS ?? 0) - expected)).toBeLessThan(expected * 0.3);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects when only one file is provided", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/merge-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.details).toMatch(/at least two/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/noise-reduction",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("noise-reduction (requires ffmpeg)", () => {
|
||||
it("denoises with medium strength and returns audio", async () => {
|
||||
const res = await runTool({ strength: "medium" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "denoise-test-"));
|
||||
const probeFile = join(tmpDir, "denoised.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio).toBeDefined();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const WAV = readFixture(fixtures.audio.tiny("wav"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.wav", contentType: "audio/wav", content: WAV },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/normalize-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("normalize-audio (requires ffmpeg)", () => {
|
||||
it("normalizes 8 kHz WAV and returns 200", async () => {
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/pitch-shift",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("pitch-shift (requires ffmpeg)", () => {
|
||||
it("shifts +12 semitones with duration roughly unchanged", async () => {
|
||||
const res = await runTool({ semitones: 12 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "pitch-test-"));
|
||||
const probeFile = join(tmpDir, "pitched.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// rubberband preserves tempo; duration should be roughly 1.044s (30% tolerance)
|
||||
expect(info.durationS).toBeGreaterThan(1.044 * 0.7);
|
||||
expect(info.durationS).toBeLessThan(1.044 * 1.3);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects semitones 0", async () => {
|
||||
const res = await runTool({ semitones: 0 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/reverse-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("reverse-audio (requires ffmpeg)", () => {
|
||||
it("reverses audio and returns 200 with duration roughly matching source", async () => {
|
||||
// Probe source duration
|
||||
const srcTmpDir = mkdtempSync(join(tmpdir(), "rev-src-"));
|
||||
const srcFile = join(srcTmpDir, "tiny.mp3");
|
||||
writeFileSync(srcFile, MP3);
|
||||
const srcInfo = await probeMedia(srcFile);
|
||||
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "rev-test-"));
|
||||
const probeFile = join(tmpDir, "reversed.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.durationS).not.toBeNull();
|
||||
// Duration should be roughly the same (30% tolerance)
|
||||
const srcDur = srcInfo.durationS ?? 1;
|
||||
expect(info.durationS as number).toBeGreaterThan(srcDur * 0.7);
|
||||
expect(info.durationS as number).toBeLessThan(srcDur * 1.3);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/ringtone-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("ringtone-maker (requires ffmpeg)", () => {
|
||||
it("creates m4r ringtone with defaults and returns 200", async () => {
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(200);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects startS beyond audio duration (422)", async () => {
|
||||
const res = await runTool({ startS: 5 });
|
||||
expect(res.statusCode).toBe(422);
|
||||
expect(res.body).toMatch(/beyond the end/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const TONE_GAP = readFixture(fixtures.audio.gap);
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/silence-removal",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("silence-removal (requires ffmpeg)", () => {
|
||||
it("removes silent gap and shortens duration below 1.05s", async () => {
|
||||
const res = await runTool({ thresholdDb: -40, minSilenceS: 0.2 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "silence-test-"));
|
||||
const probeFile = join(tmpDir, "nosilence.wav");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// tone-gap.wav is ~1.2s with a 0.4s silent middle; output should be shorter than 1.05s
|
||||
expect(info.durationS).toBeLessThan(1.05);
|
||||
expect(info.durationS).toBeGreaterThan(0.3);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
const TONE_GAP = readFixture(fixtures.audio.gap);
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("split-audio (requires ffmpeg)", () => {
|
||||
it("splits into 2 parts via parts mode", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify({ mode: "parts", parts: 2 }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
// ZIP magic: PK\x03\x04
|
||||
expect(dl.rawPayload[0]).toBe(0x50);
|
||||
expect(dl.rawPayload[1]).toBe(0x4b);
|
||||
}, 60_000);
|
||||
|
||||
it("splits by silence on tone-gap.wav", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ mode: "silence", thresholdDb: -40, minSilenceS: 0.2 }),
|
||||
},
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
// ZIP magic
|
||||
expect(dl.rawPayload[0]).toBe(0x50);
|
||||
expect(dl.rawPayload[1]).toBe(0x4b);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(400);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects silence mode when no silence found", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ mode: "silence", thresholdDb: -40, minSilenceS: 0.2 }),
|
||||
},
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.details).toMatch(/no silence found/i);
|
||||
}, 60_000);
|
||||
|
||||
it("splits by time mode on tone-gap.wav (segmentS=1)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP },
|
||||
{ name: "settings", content: JSON.stringify({ mode: "time", segmentS: 1 }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
// ZIP magic
|
||||
expect(dl.rawPayload[0]).toBe(0x50);
|
||||
expect(dl.rawPayload[1]).toBe(0x4b);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects parts=1 (schema minimum is 2)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify({ mode: "parts", parts: 1 }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Integration tests for the transcribe-audio tool (/api/v1/tools/audio/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 { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFileSync(fixtures.audio.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/audio/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/audio/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/audio/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/audio/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/audio/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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/trim-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("trim-audio (requires ffmpeg)", () => {
|
||||
it("trims mp3 from 0 to 0.5s and returns 200", async () => {
|
||||
const res = await runTool({ startS: 0, endS: 0.5 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(50);
|
||||
|
||||
// Verify trimmed duration via probeMedia (mirror trim-video's pattern)
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "trim-audio-test-"));
|
||||
const probeFile = join(tmpDir, "trimmed.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.durationS).not.toBeNull();
|
||||
// Stream-copy on mp3 may overshoot slightly due to frame boundaries,
|
||||
// but should be well under the original 1s duration.
|
||||
expect(info.durationS as number).toBeLessThanOrEqual(2);
|
||||
expect(info.durationS as number).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects when end is before start", async () => {
|
||||
const res = await runTool({ startS: 0.5, endS: 0.2 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/volume-adjust",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("volume-adjust (requires ffmpeg)", () => {
|
||||
it("adjusts volume and returns 200 with audio stream", async () => {
|
||||
const res = await runTool({ gainDb: 3 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "vol-test-"));
|
||||
const probeFile = join(tmpDir, "adjusted.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio).toBeDefined();
|
||||
}, 60_000);
|
||||
|
||||
it("rejects gainDb out of range (50)", async () => {
|
||||
const res = await runTool({ gainDb: 50 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio/waveform-image",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("waveform-image (requires ffmpeg)", () => {
|
||||
it("generates PNG waveform with default settings", async () => {
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
// PNG magic bytes: 89 50 4E 47
|
||||
expect(dl.rawPayload[0]).toBe(0x89);
|
||||
expect(dl.rawPayload[1]).toBe(0x50);
|
||||
expect(dl.rawPayload[2]).toBe(0x4e);
|
||||
expect(dl.rawPayload[3]).toBe(0x47);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects invalid color string (400)", async () => {
|
||||
const res = await runTool({ color: "blue" });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user