diff --git a/src/loaders/zipped-loader-metadata-reader.ts b/src/loaders/zipped-loader-metadata-reader.ts new file mode 100644 index 0000000..e3fe123 --- /dev/null +++ b/src/loaders/zipped-loader-metadata-reader.ts @@ -0,0 +1,115 @@ +import { Awaitable } from "@/utils/types"; +import { StreamZipAsync, async as ZipArchive } from "node-stream-zip"; +import { PathLike } from "node:fs"; +import { LoaderMetadata } from "./loader-metadata"; +import { LoaderMetadataReader } from "./loader-metadata-reader"; + +/** + * Provides a base for reading metadata from zipped files for various loaders. + * + * @template TMetadata - Represents the processed metadata object. + * @template TRawMetadata - Represents the raw metadata object to be transformed. + */ +export abstract class ZippedLoaderMetadataReader implements LoaderMetadataReader { + /** + * The name of the entry inside the zipped file to read. + */ + private readonly _entry: string; + + /** + * Constructs a new {@link ZippedLoaderMetadataReader} instance. + * + * @param entry - The name of the entry inside the zipped file to read. + */ + protected constructor(entry: string) { + this._entry = entry; + } + + /** + * Reads the metadata file from a zipped file at the given path. + * + * @param path - The path to the zipped file. + * + * @returns The metadata object, or `undefined` if the zipped file cannot be read. + */ + async readMetadataFile(path: PathLike): Promise { + let zip = undefined as StreamZipAsync; + try { + zip = new ZipArchive({ file: path as string }); + const buffer = await zip.entryData(this._entry); + if (!buffer) { + return undefined; + } + + const rawMetadata = await this.readRawMetadata(buffer); + return await this.createMetadata(rawMetadata); + } catch { + return undefined; + } finally { + await zip?.close().catch(() => undefined); + } + } + + /** + * Reads the raw metadata from a buffer. + * + * @param buffer - The buffer containing the raw metadata. + * + * @returns The raw metadata object. + */ + protected abstract readRawMetadata(buffer: Buffer): Promise; + + /** + * Creates a metadata object from the raw metadata. + * + * @param config - The raw metadata object. + * + * @returns The metadata object. + */ + protected abstract createMetadata(config: TRawMetadata): Promise; +} + +/** + * Provides a base for reading metadata from text-based files within zipped files. + * + * @template TMetadata - Represents the processed metadata object. + * @template TRawMetadata - Represents the raw metadata object to be transformed. + */ +export abstract class ZippedTextLoaderMetadataReader extends ZippedLoaderMetadataReader { + /** + * A function to transform the raw metadata into a processed metadata object. + */ + private readonly _factory: (raw: TRawMetadata) => Awaitable; + + /** + * A function to parse the text content into a raw metadata object. + */ + private readonly _parser: (text: string) => Awaitable; + + /** + * Constructs a new {@link ZippedTextLoaderMetadataReader} instance. + * + * @param entry - The name of the entry inside the zipped file to read. + * @param factory - A function to transform the raw metadata into a processed metadata object. + * @param parser - A function to parse the text content into a raw metadata object. + */ + protected constructor(entry: string, factory: (raw: TRawMetadata) => Awaitable, parser: (text: string) => Awaitable) { + super(entry); + this._factory = factory; + this._parser = parser; + } + + /** + * @inheritdoc + */ + protected async readRawMetadata(buffer: Buffer): Promise { + return await this._parser(buffer.toString()); + } + + /** + * @inheritdoc + */ + protected async createMetadata(config: TRawMetadata): Promise { + return await this._factory(config); + } +} diff --git a/tests/unit/loaders/zipped-loader-metadata-reader.spec.ts b/tests/unit/loaders/zipped-loader-metadata-reader.spec.ts new file mode 100644 index 0000000..bbaa959 --- /dev/null +++ b/tests/unit/loaders/zipped-loader-metadata-reader.spec.ts @@ -0,0 +1,100 @@ +import { zipContent } from "@/../tests/utils/zip-utils"; +import { LoaderMetadata } from "@/loaders/loader-metadata"; +import mockFs from "mock-fs"; +import { ZippedLoaderMetadataReader, ZippedTextLoaderMetadataReader } from "@/loaders/zipped-loader-metadata-reader"; + +class MockZippedLoaderMetadataReader extends ZippedLoaderMetadataReader { + constructor(entry: string) { + super(entry); + } + + protected readRawMetadata(buffer: Buffer): Promise { + return Promise.resolve(buffer.toString()); + } + + protected createMetadata(config: string): Promise { + return Promise.resolve({ id: config } as LoaderMetadata); + } +} + +class MockZippedTextLoaderMetadataReader extends ZippedTextLoaderMetadataReader { + constructor(entry: string, factory: (raw: string) => LoaderMetadata, parser: (text: string) => string) { + super(entry, factory, parser); + } +} + +beforeEach(async () => { + mockFs({ + "test.zip": await zipContent("Test", "test.txt"), + }); +}); + +afterEach(() => { + mockFs.restore(); +}); + +describe("ZippedLoaderMetadataReader", () => { + test("reads metadata file from a zipped file at the given path", async () => { + const reader = new MockZippedLoaderMetadataReader("test.txt"); + + const metadata = await reader.readMetadataFile("test.zip"); + + expect(metadata).toMatchObject({ id: "Test" }); + }); + + test("returns undefined if the given path does not exist", async () => { + const reader = new MockZippedLoaderMetadataReader("test.txt"); + + const metadata = await reader.readMetadataFile(""); + + expect(metadata).toBeUndefined(); + }); + + test("returns undefined if the zip entry does not exist", async () => { + const reader = new MockZippedLoaderMetadataReader("test"); + + const metadata = await reader.readMetadataFile("test.zip"); + + expect(metadata).toBeUndefined(); + }); +}); + +describe("ZippedTextLoaderMetadataReader", () => { + test("reads metadata file from a zipped file at the given path", async () => { + const factory = jest.fn().mockImplementation(x => ({ id: x })); + const parse = jest.fn().mockImplementation(x => [...String(x)].reverse().join("")); + const reader = new MockZippedTextLoaderMetadataReader("test.txt", factory, parse); + + const metadata = await reader.readMetadataFile("test.zip"); + + expect(metadata).toMatchObject({ id: "tseT" }); + expect(factory).toHaveBeenCalledTimes(1); + expect(factory).toHaveBeenCalledWith("tseT"); + expect(parse).toHaveBeenCalledTimes(1); + expect(parse).toHaveBeenCalledWith("Test"); + }); + + test("returns undefined if the given path does not exist", async () => { + const factory = jest.fn(); + const parse = jest.fn(); + const reader = new MockZippedTextLoaderMetadataReader("test.txt", factory, parse); + + const metadata = await reader.readMetadataFile(""); + + expect(metadata).toBeUndefined(); + expect(factory).not.toHaveBeenCalled(); + expect(parse).not.toHaveBeenCalled(); + }); + + test("returns undefined if the zip entry does not exist", async () => { + const factory = jest.fn(); + const parse = jest.fn(); + const reader = new MockZippedTextLoaderMetadataReader("test", factory, parse); + + const metadata = await reader.readMetadataFile("test.zip"); + + expect(metadata).toBeUndefined(); + expect(factory).not.toHaveBeenCalled(); + expect(parse).not.toHaveBeenCalled(); + }); +});