mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor: ♻️ Rewrite media management code
This commit is contained in:
parent
d09f74e58a
commit
faf829437d
34 changed files with 1195 additions and 904 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { proxyUrl } from "@/response";
|
||||
import type { ContentFormat } from "@lysand-org/federation/types";
|
||||
import { config } from "config-manager";
|
||||
import { MediaBackendType } from "config-manager/config.type";
|
||||
import {
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
eq,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
import { MediaBackendType } from "media-manager";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Attachments } from "~/drizzle/schema";
|
||||
import type { AsyncAttachment as APIAsyncAttachment } from "~/types/mastodon/async_attachment";
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,2 +0,0 @@
|
|||
[install.scopes]
|
||||
"@jsr" = "https://npm.jsr.io"
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import { rm } from "node:fs/promises";
|
||||
import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
|
||||
import type { Config } from "config-manager";
|
||||
import { MediaConverter } from "./media-converter";
|
||||
|
||||
export enum MediaBackendType {
|
||||
Local = "local",
|
||||
S3 = "s3",
|
||||
}
|
||||
|
||||
interface UploadedFileMetadata {
|
||||
uploadedFile: File;
|
||||
path: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export class MediaHasher {
|
||||
/**
|
||||
* Returns the SHA-256 hash of a file in hex format
|
||||
* @param media The file to hash
|
||||
* @returns The SHA-256 hash of the file in hex format
|
||||
*/
|
||||
public async getMediaHash(media: File) {
|
||||
const hash = new Bun.SHA256()
|
||||
.update(await media.arrayBuffer())
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaBackend {
|
||||
constructor(
|
||||
public config: Config,
|
||||
public backend: MediaBackendType,
|
||||
) {}
|
||||
|
||||
public static fromBackendType(
|
||||
backend: MediaBackendType,
|
||||
config: Config,
|
||||
): MediaBackend {
|
||||
switch (backend) {
|
||||
case MediaBackendType.Local:
|
||||
return new LocalMediaBackend(config);
|
||||
case MediaBackendType.S3:
|
||||
return new S3MediaBackend(config);
|
||||
default:
|
||||
throw new Error(`Unknown backend type: ${backend as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
public getBackendType() {
|
||||
return this.backend;
|
||||
}
|
||||
|
||||
public shouldConvertImages(config: Config) {
|
||||
return config.media.conversion.convert_images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches file from backend from SHA-256 hash
|
||||
* @param file SHA-256 hash of wanted file
|
||||
* @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database
|
||||
* @returns The file as a File object
|
||||
*/
|
||||
public getFileByHash(
|
||||
_file: string,
|
||||
_databaseHashFetcher: (sha256: string) => Promise<string>,
|
||||
): Promise<File | null> {
|
||||
return Promise.reject(
|
||||
new Error("Do not call MediaBackend directly: use a subclass"),
|
||||
);
|
||||
}
|
||||
|
||||
public deleteFileByUrl(_url: string): Promise<void> {
|
||||
return Promise.reject(
|
||||
new Error("Do not call MediaBackend directly: use a subclass"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches file from backend from filename
|
||||
* @param filename File name
|
||||
* @returns The file as a File object
|
||||
*/
|
||||
public getFile(_filename: string): Promise<File | null> {
|
||||
return Promise.reject(
|
||||
new Error("Do not call MediaBackend directly: use a subclass"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds file to backend
|
||||
* @param file File to add
|
||||
* @returns Metadata about the uploaded file
|
||||
*/
|
||||
public addFile(_file: File): Promise<UploadedFileMetadata> {
|
||||
return Promise.reject(
|
||||
new Error("Do not call MediaBackend directly: use a subclass"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalMediaBackend extends MediaBackend {
|
||||
constructor(config: Config) {
|
||||
super(config, MediaBackendType.Local);
|
||||
}
|
||||
|
||||
public async addFile(file: File) {
|
||||
let convertedFile = file;
|
||||
if (this.shouldConvertImages(this.config)) {
|
||||
const mediaConverter = new MediaConverter();
|
||||
convertedFile = await mediaConverter.convert(
|
||||
file,
|
||||
this.config.media.conversion.convert_to,
|
||||
);
|
||||
}
|
||||
|
||||
const hash = await new MediaHasher().getMediaHash(convertedFile);
|
||||
|
||||
const newFile = Bun.file(
|
||||
`${this.config.media.local_uploads_folder}/${hash}/${convertedFile.name}`,
|
||||
);
|
||||
|
||||
if (await newFile.exists()) {
|
||||
// Already exists, we don't need to upload it again
|
||||
return {
|
||||
uploadedFile: convertedFile,
|
||||
path: `${hash}/${convertedFile.name}`,
|
||||
hash: hash,
|
||||
};
|
||||
}
|
||||
|
||||
await Bun.write(newFile, convertedFile);
|
||||
|
||||
return {
|
||||
uploadedFile: convertedFile,
|
||||
path: `${hash}/${convertedFile.name}`,
|
||||
hash: hash,
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteFileByUrl(url: string) {
|
||||
// url is of format https://base-url/media/SHA256HASH/FILENAME
|
||||
const urlO = new URL(url);
|
||||
|
||||
const hash = urlO.pathname.split("/")[1];
|
||||
|
||||
const dirPath = `${this.config.media.local_uploads_folder}/${hash}`;
|
||||
|
||||
try {
|
||||
await rm(dirPath, { recursive: true });
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete directory at ${dirPath}`);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
public async getFileByHash(
|
||||
hash: string,
|
||||
databaseHashFetcher: (sha256: string) => Promise<string | null>,
|
||||
): Promise<File | null> {
|
||||
const filename = await databaseHashFetcher(hash);
|
||||
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getFile(filename);
|
||||
}
|
||||
|
||||
public async getFile(filename: string): Promise<File | null> {
|
||||
const file = Bun.file(
|
||||
`${this.config.media.local_uploads_folder}/${filename}`,
|
||||
);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new File([await file.arrayBuffer()], filename, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class S3MediaBackend extends MediaBackend {
|
||||
constructor(
|
||||
config: Config,
|
||||
private s3Client = new S3Client({
|
||||
endPoint: config.s3.endpoint,
|
||||
useSSL: true,
|
||||
region: config.s3.region || "auto",
|
||||
bucket: config.s3.bucket_name,
|
||||
accessKey: config.s3.access_key,
|
||||
secretKey: config.s3.secret_access_key,
|
||||
}),
|
||||
) {
|
||||
super(config, MediaBackendType.S3);
|
||||
}
|
||||
|
||||
public async deleteFileByUrl(url: string) {
|
||||
// url is of format https://s3-base-url/SHA256HASH/FILENAME
|
||||
const urlO = new URL(url);
|
||||
|
||||
const hash = urlO.pathname.split("/")[1];
|
||||
const filename = urlO.pathname.split("/")[2];
|
||||
|
||||
await this.s3Client.deleteObject(`${hash}/${filename}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
public async addFile(file: File) {
|
||||
let convertedFile = file;
|
||||
if (this.shouldConvertImages(this.config)) {
|
||||
const mediaConverter = new MediaConverter();
|
||||
convertedFile = await mediaConverter.convert(
|
||||
file,
|
||||
this.config.media.conversion.convert_to,
|
||||
);
|
||||
}
|
||||
|
||||
const hash = await new MediaHasher().getMediaHash(convertedFile);
|
||||
|
||||
await this.s3Client.putObject(
|
||||
`${hash}/${convertedFile.name}`,
|
||||
convertedFile.stream(),
|
||||
{
|
||||
size: convertedFile.size,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
uploadedFile: convertedFile,
|
||||
path: `${hash}/${convertedFile.name}`,
|
||||
hash: hash,
|
||||
};
|
||||
}
|
||||
|
||||
public async getFileByHash(
|
||||
hash: string,
|
||||
databaseHashFetcher: (sha256: string) => Promise<string | null>,
|
||||
): Promise<File | null> {
|
||||
const filename = await databaseHashFetcher(hash);
|
||||
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getFile(filename);
|
||||
}
|
||||
|
||||
public async getFile(filename: string): Promise<File | null> {
|
||||
try {
|
||||
await this.s3Client.statObject(filename);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = await this.s3Client.getObject(filename);
|
||||
|
||||
return new File([await file.arrayBuffer()], filename, {
|
||||
type: file.headers.get("Content-Type") || "undefined",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { MediaConverter };
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
/**
|
||||
* @packageDocumentation
|
||||
* @module MediaManager
|
||||
* @description Handles media conversion between formats
|
||||
*/
|
||||
import { config } from "config-manager";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const supportedMediaFormats = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
export const supportedOutputFormats = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
/**
|
||||
* Handles media conversion between formats
|
||||
*/
|
||||
export class MediaConverter {
|
||||
/**
|
||||
* Returns whether the media is convertable
|
||||
* @returns Whether the media is convertable
|
||||
*/
|
||||
public isConvertable(file: File) {
|
||||
if (
|
||||
file.type === "image/svg+xml" &&
|
||||
!config.media.conversion.convert_vector
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return supportedMediaFormats.includes(file.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name with the extension replaced
|
||||
* @param fileName File name to replace
|
||||
* @returns File name with extension replaced
|
||||
*/
|
||||
private getReplacedFileName(fileName: string, newExtension: string) {
|
||||
return this.extractFilenameFromPath(fileName).replace(
|
||||
/\.[^/.]+$/,
|
||||
`.${newExtension}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the filename from a path
|
||||
* @param path Path to extract filename from
|
||||
* @returns Extracted filename
|
||||
*/
|
||||
private extractFilenameFromPath(path: string) {
|
||||
// Don't count escaped slashes as path separators
|
||||
const pathParts = path.split(/(?<!\\)\//);
|
||||
return pathParts[pathParts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts media to the specified format
|
||||
* @param media Media to convert
|
||||
* @returns Converted media
|
||||
*/
|
||||
public async convert(
|
||||
media: File,
|
||||
toMime: (typeof supportedMediaFormats)[number],
|
||||
) {
|
||||
if (!this.isConvertable(media)) {
|
||||
return media;
|
||||
}
|
||||
|
||||
if (!supportedOutputFormats.includes(toMime)) {
|
||||
throw new Error(
|
||||
`Unsupported image output format: ${toMime}. Supported formats: ${supportedOutputFormats.join(
|
||||
", ",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sharpCommand = sharp(await media.arrayBuffer(), {
|
||||
animated: true,
|
||||
});
|
||||
|
||||
const commandName = toMime.split("/")[1] as
|
||||
| "jpeg"
|
||||
| "png"
|
||||
| "webp"
|
||||
| "avif"
|
||||
| "gif"
|
||||
| "tiff";
|
||||
|
||||
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
|
||||
|
||||
// Convert the buffer to a BlobPart
|
||||
const buffer = new Blob([convertedBuffer]);
|
||||
|
||||
return new File(
|
||||
[buffer],
|
||||
this.getReplacedFileName(media.name || "image", commandName),
|
||||
{
|
||||
type: toMime,
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"name": "media-manager",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@jsr/bradenmacdonald__s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client",
|
||||
"config-manager": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, jest, mock, spyOn } from "bun:test";
|
||||
import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
|
||||
import type { Config } from "config-manager";
|
||||
import {
|
||||
LocalMediaBackend,
|
||||
MediaBackend,
|
||||
MediaBackendType,
|
||||
MediaHasher,
|
||||
S3MediaBackend,
|
||||
} from "../index";
|
||||
import { MediaConverter } from "../media-converter";
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
};
|
||||
|
||||
describe("MediaBackend", () => {
|
||||
let mediaBackend: MediaBackend;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
media: {
|
||||
conversion: {
|
||||
convert_images: true,
|
||||
},
|
||||
},
|
||||
} as Config;
|
||||
mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3);
|
||||
});
|
||||
|
||||
it("should initialize with correct backend type", () => {
|
||||
expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
|
||||
});
|
||||
|
||||
describe("fromBackendType", () => {
|
||||
it("should return a LocalMediaBackend instance for LOCAL backend type", async () => {
|
||||
const backend = await MediaBackend.fromBackendType(
|
||||
MediaBackendType.Local,
|
||||
mockConfig,
|
||||
);
|
||||
expect(backend).toBeInstanceOf(LocalMediaBackend);
|
||||
});
|
||||
|
||||
it("should return a S3MediaBackend instance for S3 backend type", async () => {
|
||||
const backend = await MediaBackend.fromBackendType(
|
||||
MediaBackendType.S3,
|
||||
{
|
||||
s3: {
|
||||
endpoint: "localhost:4566",
|
||||
region: "us-east-1",
|
||||
bucket_name: "test-bucket",
|
||||
access_key: "test-access",
|
||||
public_url: "test",
|
||||
secret_access_key: "test-secret",
|
||||
},
|
||||
} as Config,
|
||||
);
|
||||
expect(backend).toBeInstanceOf(S3MediaBackend);
|
||||
});
|
||||
|
||||
it("should throw an error for unknown backend type", () => {
|
||||
expect(
|
||||
// @ts-expect-error This is a test
|
||||
() => MediaBackend.fromBackendType("unknown", mockConfig),
|
||||
).toThrow("Unknown backend type: unknown");
|
||||
});
|
||||
});
|
||||
|
||||
it("should check if images should be converted", () => {
|
||||
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true);
|
||||
mockConfig.media.conversion.convert_images = false;
|
||||
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw error when calling getFileByHash", () => {
|
||||
const mockHash = "test-hash";
|
||||
const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg");
|
||||
|
||||
expect(
|
||||
mediaBackend.getFileByHash(mockHash, databaseHashFetcher),
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
it("should throw error when calling getFile", () => {
|
||||
const mockFilename = "test.jpg";
|
||||
|
||||
expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
it("should throw error when calling addFile", () => {
|
||||
const mockFile = new File([""], "test.jpg");
|
||||
|
||||
expect(mediaBackend.addFile(mockFile)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("S3MediaBackend", () => {
|
||||
let s3MediaBackend: S3MediaBackend;
|
||||
let mockS3Client: Partial<S3Client>;
|
||||
let mockConfig: DeepPartial<Config>;
|
||||
let mockFile: File;
|
||||
let mockMediaHasher: MediaHasher;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
s3: {
|
||||
endpoint: "http://localhost:4566",
|
||||
region: "us-east-1",
|
||||
bucket_name: "test-bucket",
|
||||
access_key: "test-access-key",
|
||||
secret_access_key: "test-secret-access-key",
|
||||
public_url: "test",
|
||||
},
|
||||
media: {
|
||||
conversion: {
|
||||
convert_to: "image/png",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockFile = new File([new TextEncoder().encode("test")], "test.jpg");
|
||||
mockMediaHasher = new MediaHasher();
|
||||
mockS3Client = {
|
||||
putObject: jest.fn().mockResolvedValue({}),
|
||||
statObject: jest.fn().mockResolvedValue({}),
|
||||
getObject: jest.fn().mockResolvedValue({
|
||||
blob: jest.fn().mockResolvedValue(new Blob()),
|
||||
headers: new Headers({ "Content-Type": "image/jpeg" }),
|
||||
}),
|
||||
deleteObject: jest.fn().mockResolvedValue({}),
|
||||
} as Partial<S3Client>;
|
||||
s3MediaBackend = new S3MediaBackend(
|
||||
mockConfig as Config,
|
||||
mockS3Client as S3Client,
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize with correct type", () => {
|
||||
expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
|
||||
});
|
||||
|
||||
it("should add file", async () => {
|
||||
const mockHash = "test-hash";
|
||||
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
|
||||
|
||||
const result = await s3MediaBackend.addFile(mockFile);
|
||||
|
||||
expect(result.uploadedFile).toEqual(mockFile);
|
||||
expect(result.hash).toHaveLength(64);
|
||||
expect(mockS3Client.putObject).toHaveBeenCalledWith(
|
||||
expect.stringContaining(mockFile.name),
|
||||
expect.any(ReadableStream),
|
||||
{ size: mockFile.size },
|
||||
);
|
||||
});
|
||||
|
||||
it("should get file by hash", async () => {
|
||||
const mockHash = "test-hash";
|
||||
const mockFilename = "test.jpg";
|
||||
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
|
||||
mockS3Client.statObject = jest.fn().mockResolvedValue({});
|
||||
mockS3Client.getObject = jest.fn().mockResolvedValue({
|
||||
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
|
||||
headers: new Headers({ "Content-Type": "image/jpeg" }),
|
||||
});
|
||||
|
||||
const file = await s3MediaBackend.getFileByHash(
|
||||
mockHash,
|
||||
databaseHashFetcher,
|
||||
);
|
||||
|
||||
expect(file).not.toBeNull();
|
||||
expect(file?.name).toEqual(mockFilename);
|
||||
expect(file?.type).toEqual("image/jpeg");
|
||||
});
|
||||
|
||||
it("should get file", async () => {
|
||||
const mockFilename = "test.jpg";
|
||||
mockS3Client.statObject = jest.fn().mockResolvedValue({});
|
||||
mockS3Client.getObject = jest.fn().mockResolvedValue({
|
||||
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
|
||||
headers: new Headers({ "Content-Type": "image/jpeg" }),
|
||||
});
|
||||
|
||||
const file = await s3MediaBackend.getFile(mockFilename);
|
||||
|
||||
expect(file).not.toBeNull();
|
||||
expect(file?.name).toEqual(mockFilename);
|
||||
expect(file?.type).toEqual("image/jpeg");
|
||||
});
|
||||
|
||||
it("should delete file", async () => {
|
||||
// deleteFileByUrl
|
||||
// Upload file first
|
||||
const mockHash = "test-hash";
|
||||
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
|
||||
const result = await s3MediaBackend.addFile(mockFile);
|
||||
const url = result.path;
|
||||
|
||||
await s3MediaBackend.deleteFileByUrl(`http://localhost:4566/${url}`);
|
||||
|
||||
expect(mockS3Client.deleteObject).toHaveBeenCalledWith(
|
||||
expect.stringContaining(url),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMediaBackend", () => {
|
||||
let localMediaBackend: LocalMediaBackend;
|
||||
let mockConfig: Config;
|
||||
let mockFile: File;
|
||||
let mockMediaHasher: MediaHasher;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
media: {
|
||||
conversion: {
|
||||
convert_images: true,
|
||||
convert_to: "image/png",
|
||||
},
|
||||
local_uploads_folder: "./uploads",
|
||||
},
|
||||
} as Config;
|
||||
mockFile = Bun.file(`${__dirname}/megamind.jpg`) as unknown as File;
|
||||
mockMediaHasher = new MediaHasher();
|
||||
localMediaBackend = new LocalMediaBackend(mockConfig);
|
||||
});
|
||||
|
||||
it("should initialize with correct type", () => {
|
||||
expect(localMediaBackend.getBackendType()).toEqual(
|
||||
MediaBackendType.Local,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add file", async () => {
|
||||
const mockHash = "test-hash";
|
||||
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
|
||||
const mockMediaConverter = new MediaConverter();
|
||||
spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile);
|
||||
// @ts-expect-error This is a mock
|
||||
spyOn(Bun, "file").mockImplementationOnce(() => ({
|
||||
exists: () => Promise.resolve(false),
|
||||
}));
|
||||
spyOn(Bun, "write").mockImplementationOnce(() =>
|
||||
Promise.resolve(mockFile.size),
|
||||
);
|
||||
|
||||
const result = await localMediaBackend.addFile(mockFile);
|
||||
|
||||
expect(result.uploadedFile).toEqual(mockFile);
|
||||
expect(result.path).toEqual(expect.stringContaining("megamind.png"));
|
||||
expect(result.hash).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("should get file by hash", async () => {
|
||||
const mockHash = "test-hash";
|
||||
const mockFilename = "test.jpg";
|
||||
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
|
||||
// @ts-expect-error This is a mock
|
||||
spyOn(Bun, "file").mockImplementationOnce(() => ({
|
||||
exists: () => Promise.resolve(true),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
type: "image/jpeg",
|
||||
lastModified: 123456789,
|
||||
}));
|
||||
|
||||
const file = await localMediaBackend.getFileByHash(
|
||||
mockHash,
|
||||
databaseHashFetcher,
|
||||
);
|
||||
|
||||
expect(file).not.toBeNull();
|
||||
expect(file?.name).toEqual(mockFilename);
|
||||
expect(file?.type).toEqual("image/jpeg");
|
||||
});
|
||||
|
||||
it("should get file", async () => {
|
||||
const mockFilename = "test.jpg";
|
||||
// @ts-expect-error This is a mock
|
||||
spyOn(Bun, "file").mockImplementationOnce(() => ({
|
||||
exists: () => Promise.resolve(true),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
type: "image/jpeg",
|
||||
lastModified: 123456789,
|
||||
}));
|
||||
|
||||
const file = await localMediaBackend.getFile(mockFilename);
|
||||
|
||||
expect(file).not.toBeNull();
|
||||
expect(file?.name).toEqual(mockFilename);
|
||||
expect(file?.type).toEqual("image/jpeg");
|
||||
});
|
||||
|
||||
it("should delete file", async () => {
|
||||
// deleteByUrl
|
||||
const mockHash = "test-hash";
|
||||
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
|
||||
await localMediaBackend.addFile(mockFile);
|
||||
const rmMock = jest.fn().mockResolvedValue(Promise.resolve());
|
||||
|
||||
// Spy on fs/promises rm
|
||||
mock.module("fs/promises", () => {
|
||||
return {
|
||||
rm: rmMock,
|
||||
};
|
||||
});
|
||||
|
||||
await localMediaBackend.deleteFileByUrl(
|
||||
"http://localhost:4566/test-hash",
|
||||
);
|
||||
|
||||
expect(rmMock).toHaveBeenCalledWith(
|
||||
`${mockConfig.media.local_uploads_folder}/${mockHash}`,
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { MediaConverter } from "../media-converter";
|
||||
|
||||
describe("MediaConverter", () => {
|
||||
let mediaConverter: MediaConverter;
|
||||
|
||||
beforeEach(() => {
|
||||
mediaConverter = new MediaConverter();
|
||||
});
|
||||
|
||||
it("should replace file name extension", () => {
|
||||
const fileName = "test.jpg";
|
||||
const expectedFileName = "test.png";
|
||||
// Written like this because it's a private function
|
||||
// @ts-ignore
|
||||
expect(mediaConverter.getReplacedFileName(fileName, "png")).toEqual(
|
||||
expectedFileName,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Filename extractor", () => {
|
||||
it("should extract filename from path", () => {
|
||||
const path = "path/to/test.jpg";
|
||||
const expectedFileName = "test.jpg";
|
||||
// @ts-ignore
|
||||
expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
|
||||
expectedFileName,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle escaped slashes", () => {
|
||||
const path = "path/to/test\\/test.jpg";
|
||||
const expectedFileName = "test\\/test.jpg";
|
||||
// @ts-ignore
|
||||
expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
|
||||
expectedFileName,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert media", async () => {
|
||||
const file = Bun.file(`${__dirname}/megamind.jpg`);
|
||||
|
||||
const convertedFile = await mediaConverter.convert(
|
||||
file as unknown as File,
|
||||
"image/png",
|
||||
);
|
||||
|
||||
expect(convertedFile.name).toEqual("megamind.png");
|
||||
expect(convertedFile.type).toEqual("image/png");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB |
Loading…
Add table
Add a link
Reference in a new issue