Add media hosting and store tests
This commit is contained in:
93
src/media/host.test.ts
Normal file
93
src/media/host.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const saveMediaSource = vi.fn();
|
||||||
|
const getTailnetHostname = vi.fn();
|
||||||
|
const ensurePortAvailable = vi.fn();
|
||||||
|
const startMediaServer = vi.fn();
|
||||||
|
const logInfo = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./store.js", () => ({ saveMediaSource }));
|
||||||
|
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
||||||
|
vi.mock("../infra/ports.js", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||||
|
"../infra/ports.js",
|
||||||
|
);
|
||||||
|
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||||
|
});
|
||||||
|
vi.mock("./server.js", () => ({ startMediaServer }));
|
||||||
|
vi.mock("../logger.js", () => ({ logInfo }));
|
||||||
|
|
||||||
|
const { ensureMediaHosted } = await import("./host.js");
|
||||||
|
const { PortInUseError } = await import("../infra/ports.js");
|
||||||
|
|
||||||
|
describe("ensureMediaHosted", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws and cleans up when server not allowed to start", async () => {
|
||||||
|
saveMediaSource.mockResolvedValue({
|
||||||
|
id: "id1",
|
||||||
|
path: "/tmp/file1",
|
||||||
|
size: 5,
|
||||||
|
});
|
||||||
|
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||||
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
|
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||||
|
).rejects.toThrow("requires the webhook/Funnel server");
|
||||||
|
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||||
|
rmSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts media server when allowed", async () => {
|
||||||
|
saveMediaSource.mockResolvedValue({
|
||||||
|
id: "id2",
|
||||||
|
path: "/tmp/file2",
|
||||||
|
size: 9,
|
||||||
|
});
|
||||||
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
|
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||||
|
startMediaServer.mockResolvedValue(fakeServer);
|
||||||
|
|
||||||
|
const result = await ensureMediaHosted("/tmp/file2", {
|
||||||
|
startServer: true,
|
||||||
|
port: 1234,
|
||||||
|
});
|
||||||
|
expect(startMediaServer).toHaveBeenCalledWith(
|
||||||
|
1234,
|
||||||
|
expect.any(Number),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(logInfo).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
url: "https://tail.net/media/id2",
|
||||||
|
id: "id2",
|
||||||
|
size: 9,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips server start when port already in use", async () => {
|
||||||
|
saveMediaSource.mockResolvedValue({
|
||||||
|
id: "id3",
|
||||||
|
path: "/tmp/file3",
|
||||||
|
size: 7,
|
||||||
|
});
|
||||||
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
|
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||||
|
|
||||||
|
const result = await ensureMediaHosted("/tmp/file3", {
|
||||||
|
startServer: false,
|
||||||
|
port: 3000,
|
||||||
|
});
|
||||||
|
expect(startMediaServer).not.toHaveBeenCalled();
|
||||||
|
expect(result.url).toBe("https://tail.net/media/id3");
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/media/server.test.ts
Normal file
52
src/media/server.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
|
||||||
|
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.mock("./store.js", () => ({
|
||||||
|
getMediaDir: () => MEDIA_DIR,
|
||||||
|
cleanOldMedia,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { startMediaServer } = await import("./server.js");
|
||||||
|
|
||||||
|
describe("media server", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves media and cleans up after send", async () => {
|
||||||
|
const file = path.join(MEDIA_DIR, "file1");
|
||||||
|
await fs.writeFile(file, "hello");
|
||||||
|
const server = await startMediaServer(0, 5_000);
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.text()).toBe("hello");
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires old media", async () => {
|
||||||
|
const file = path.join(MEDIA_DIR, "old");
|
||||||
|
await fs.writeFile(file, "stale");
|
||||||
|
const past = Date.now() - 10_000;
|
||||||
|
await fs.utimes(file, past / 1000, past / 1000);
|
||||||
|
const server = await startMediaServer(0, 1_000);
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||||
|
expect(res.status).toBe(410);
|
||||||
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/media/store.test.ts
Normal file
60
src/media/store.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
||||||
|
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
||||||
|
|
||||||
|
vi.mock("node:os", () => ({
|
||||||
|
default: { homedir: () => HOME },
|
||||||
|
homedir: () => HOME,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = await import("./store.js");
|
||||||
|
|
||||||
|
describe("media store", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and returns media directory", async () => {
|
||||||
|
const dir = await store.ensureMediaDir();
|
||||||
|
expect(dir).toContain("warelay-home-test");
|
||||||
|
const stat = await fs.stat(dir);
|
||||||
|
expect(stat.isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves buffers and enforces size limit", async () => {
|
||||||
|
const buf = Buffer.from("hello");
|
||||||
|
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||||
|
const savedStat = await fs.stat(saved.path);
|
||||||
|
expect(savedStat.size).toBe(buf.length);
|
||||||
|
expect(saved.contentType).toBe("text/plain");
|
||||||
|
|
||||||
|
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||||
|
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||||
|
"Media exceeds 5MB limit",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies local files and cleans old media", async () => {
|
||||||
|
const srcFile = path.join(HOME, "tmp-src.txt");
|
||||||
|
await fs.mkdir(HOME, { recursive: true });
|
||||||
|
await fs.writeFile(srcFile, "local file");
|
||||||
|
const saved = await store.saveMediaSource(srcFile);
|
||||||
|
expect(saved.size).toBe(10);
|
||||||
|
const savedStat = await fs.stat(saved.path);
|
||||||
|
expect(savedStat.isFile()).toBe(true);
|
||||||
|
|
||||||
|
// make the file look old and ensure cleanOldMedia removes it
|
||||||
|
const past = Date.now() - 10_000;
|
||||||
|
await fs.utimes(saved.path, past / 1000, past / 1000);
|
||||||
|
await store.cleanOldMedia(1);
|
||||||
|
await expect(fs.stat(saved.path)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user