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:
SnapOtter
2026-06-21 02:18:53 +08:00
547 changed files with 37191 additions and 4059 deletions
@@ -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);
});
});