diff --git a/src/media/host.test.ts b/src/media/host.test.ts new file mode 100644 index 000000000..3c6d587d6 --- /dev/null +++ b/src/media/host.test.ts @@ -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( + "../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"); + }); +}); diff --git a/src/media/server.test.ts b/src/media/server.test.ts new file mode 100644 index 000000000..aa5620de6 --- /dev/null +++ b/src/media/server.test.ts @@ -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)); + }); +}); diff --git a/src/media/store.test.ts b/src/media/store.test.ts new file mode 100644 index 000000000..7db7b832f --- /dev/null +++ b/src/media/store.test.ts @@ -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("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(); + }); +});