From 704d99c1fd6f9df8a5d9caf94e9c2fabf2b1ce82 Mon Sep 17 00:00:00 2001 From: Sabyasachi Date: Fri, 14 Aug 2026 09:30:44 +0000 Subject: [PATCH 1/3] chore(deps): fastify-plugin 6, drop unused testcontainers, CI on node 24 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/npm-publish-github-packages.yml | 4 ++-- .github/workflows/test.yml | 2 +- package.json | 10 ++++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/npm-publish-github-packages.yml b/.github/workflows/npm-publish-github-packages.yml index 9e7c986..6ed5bfe 100644 --- a/.github/workflows/npm-publish-github-packages.yml +++ b/.github/workflows/npm-publish-github-packages.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - run: npm install - run: npm test @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 registry-url: https://npm.pkg.github.com/ - run: npm install - run: npm publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 987cce2..6f3d722 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,6 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - run: npm install - run: npm test diff --git a/package.json b/package.json index c91b850..04215e1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "dist", "LICENSE" ], + "engines": { + "node": ">=22" + }, "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", "@aws-sdk/credential-provider-node": "^3.972.39", @@ -34,7 +37,7 @@ "@azure/identity": "^4.13.1", "@azure/storage-blob": "^12.31.0", "@google-cloud/storage": "^7.19.0", - "fastify-plugin": "^5.1.0" + "fastify-plugin": "^6.0.0" }, "peerDependencies": { "fastify": "^3.0.0 || ^4.0.0 || ^5.0.0" @@ -42,14 +45,13 @@ "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/jest": "^30.0.0", - "@types/node": "^25.6.2", + "@types/node": "^26.2.0", "husky": "^9.1.7", "jest": "^30.4.2", "lint-staged": "^17.0.4", - "npm-run-all": "^4.1.5", + "npm-run-all2": "^9.0.3", "prettier": "^3.8.3", "rimraf": "^6.1.3", - "testcontainers": "^11.14.0", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "typescript": "^6.0.3" From 4bf4ecd98e4ea22b66356e390412e52d1791054b Mon Sep 17 00:00:00 2001 From: Sabyasachi Date: Fri, 14 Aug 2026 09:36:31 +0000 Subject: [PATCH 2/3] perf(file-store): lazy-require cloud SDKs so only the active provider loads Importing the AWS, Azure and GCS SDKs at module scope cost ~360ms and ~31MB of heap on every boot, though a deployment only ever uses one backend. Loading each via its Configure* entry point drops package require to ~5ms and leaves the other two SDKs untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/file-store.ts | 52 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/src/file-store.ts b/src/file-store.ts index 4a2b5d0..8943707 100644 --- a/src/file-store.ts +++ b/src/file-store.ts @@ -2,16 +2,41 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import * as stream from "node:stream"; -import * as S3 from "@aws-sdk/client-s3"; -import * as AzureIden from "@azure/identity"; -import { defaultProvider } from "@aws-sdk/credential-provider-node"; -import { Upload } from "@aws-sdk/lib-storage"; -import { BlobServiceClient, ContainerClient } from "@azure/storage-blob"; -import { Storage } from "@google-cloud/storage"; +import type * as S3Mod from "@aws-sdk/client-s3"; +import type * as AzureBlobMod from "@azure/storage-blob"; +import type * as GcsMod from "@google-cloud/storage"; +import type { BlobServiceClient, ContainerClient } from "@azure/storage-blob"; import { FastifyInstance, FastifyPluginAsync } from "fastify"; import fp from "fastify-plugin"; import { streamToBuffer } from "./utils"; +// The cloud SDKs are require()d by the provider that needs them rather than imported +// at module scope. A deployment only ever uses one backend, but loading all three +// costs ~360ms and ~31MB of heap on every boot (GCS alone is ~123ms / ~13.6MB). +// +// Each holder is assigned by its load* function before any class that reads it can be +// constructed — every FileStore implementation is private to this module and reachable +// only through the matching Configure* entry point below. +let S3!: typeof S3Mod; +let Upload!: typeof import("@aws-sdk/lib-storage").Upload; +let AzureBlob!: typeof AzureBlobMod; +let AzureIden!: typeof import("@azure/identity"); +let Gcs!: typeof GcsMod; + +function loadAWS(): void { + S3 ??= require("@aws-sdk/client-s3"); + Upload ??= require("@aws-sdk/lib-storage").Upload; +} + +function loadAzure(): void { + AzureBlob ??= require("@azure/storage-blob"); + AzureIden ??= require("@azure/identity"); +} + +function loadGCP(): void { + Gcs ??= require("@google-cloud/storage"); +} + export interface FileInfo { size: number; contentType: string; @@ -226,10 +251,10 @@ class AzureFileStore implements FileStore { } class GCPFileStore implements FileStore { - private storage: Storage; + private storage: GcsMod.Storage; constructor(private bucket: string) { - this.storage = new Storage(); + this.storage = new Gcs.Storage(); } async exists(filepath: string): Promise { @@ -310,7 +335,7 @@ class GCPFileStore implements FileStore { class S3FileStore implements FileStore { constructor( - private client: S3.S3Client, + private client: S3Mod.S3Client, private bucket: string, ) {} @@ -452,7 +477,8 @@ async function ConfigureAzure(f: FastifyInstance) { if (!process.env.AZURE_STORAGE_CONTAINER) { throw new Error("AZURE_STORAGE_CONTAINER is not defined"); } - const accountClient = new BlobServiceClient( + loadAzure(); + const accountClient = new AzureBlob.BlobServiceClient( process.env.AZURE_STORAGE_ACCOUNT_URL, new AzureIden.DefaultAzureCredential({}), {}, @@ -468,13 +494,16 @@ async function ConfigureGCP(f: FastifyInstance) { if (!bucket) { throw new Error("STORAGE_BUCKET env-var is not defined"); } + loadGCP(); f.decorate("FileStore", new GCPFileStore(bucket)); } async function ConfigureAWS(f: FastifyInstance) { + loadAWS(); const client = new S3.S3Client({ region: process.env.AWS_S3_REGION ?? "us-east-1", - credentialDefaultProvider: defaultProvider, + credentialDefaultProvider: + require("@aws-sdk/credential-provider-node").defaultProvider, }); const bucket = process.env.S3_BUCKET; @@ -495,6 +524,7 @@ async function ConfigureMinio(f: FastifyInstance) { if (!process.env.MINIO_SECRET_ACCESS_KEY) { throw new Error("MINIO_SECRET_ACCESS_KEY env-var is not defined"); } + loadAWS(); const client = new S3.S3Client({ region: process.env.MINIO_REGION ?? "us-east-1", endpoint: process.env.MINIO_ENDPOINT, From 7339d95e2151e9650d60257aa854df1344ec8ca9 Mon Sep 17 00:00:00 2001 From: Sabyasachi Date: Fri, 14 Aug 2026 09:48:16 +0000 Subject: [PATCH 3/3] test(file-store): 100% coverage, fix ts-jest coverage source mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsconfig sets sourceMap:false, so istanbul reported emitted-JS line numbers rather than TypeScript ones — parameter properties expand on compile, so the report blamed imports and comments and understated real coverage as ~95%. Overriding sourceMap for the jest transform only makes it accurate; the published build is unchanged. Covers every provider and branch across four new specs: 38 -> 121 tests. Co-Authored-By: Claude Opus 5 (1M context) --- jest.config.js | 5 + src/file-store.azure.spec.ts | 239 +++++++++++++++++++++++++++ src/file-store.gcs.spec.ts | 235 ++++++++++++++++++++++++++ src/file-store.local.spec.ts | 241 +++++++++++++++++++++++++++ src/file-store.s3.spec.ts | 312 +++++++++++++++++++++++++++++++++++ src/file-store.ts | 12 +- 6 files changed, 1039 insertions(+), 5 deletions(-) create mode 100644 src/file-store.azure.spec.ts create mode 100644 src/file-store.gcs.spec.ts create mode 100644 src/file-store.local.spec.ts create mode 100644 src/file-store.s3.spec.ts diff --git a/jest.config.js b/jest.config.js index 9c9846e..3d71028 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,6 +12,11 @@ module.exports = { diagnostics: false, isolatedModules: false, include: [], + // tsconfig.json sets sourceMap:false, which leaves istanbul reporting emitted-JS + // line numbers — TS parameter properties expand on compile, so the coverage + // report pointed at imports and comments. Overridden here only; the published + // build still comes from tsconfig.build.json without source maps. + tsconfig: { sourceMap: true }, }, ], }, diff --git a/src/file-store.azure.spec.ts b/src/file-store.azure.spec.ts new file mode 100644 index 0000000..1337bb7 --- /dev/null +++ b/src/file-store.azure.spec.ts @@ -0,0 +1,239 @@ +import { Readable } from "node:stream"; +import { BlobServiceClient } from "@azure/storage-blob"; +import Fastify from "fastify"; +import FileStorePlugin, { FileStore } from "./file-store"; + +jest.mock("@azure/storage-blob"); +jest.mock("@azure/identity"); + +describe("AzureFileStore", () => { + let fastify: ReturnType; + let mockBlobClient: any; + let mockContainerClient: any; + let store: FileStore; + + beforeEach(async () => { + jest.clearAllMocks(); + fastify = Fastify(); + + mockBlobClient = { + exists: jest.fn(), + uploadData: jest.fn(), + uploadFile: jest.fn(), + uploadStream: jest.fn(), + download: jest.fn(), + getProperties: jest.fn(), + }; + + mockContainerClient = { + getBlobClient: jest.fn(() => mockBlobClient), + getBlockBlobClient: jest.fn(() => mockBlobClient), + }; + + (BlobServiceClient as jest.Mock).mockImplementation(() => ({ + getContainerClient: jest.fn(() => mockContainerClient), + })); + + process.env.AZURE_STORAGE_ACCOUNT_URL = + "https://test.blob.core.windows.net"; + process.env.AZURE_STORAGE_CONTAINER = "test-container"; + + await fastify.register(FileStorePlugin, { type: "azureBlob" }); + store = fastify.FileStore; + }); + + afterEach(async () => { + await fastify.close(); + delete process.env.AZURE_STORAGE_ACCOUNT_URL; + delete process.env.AZURE_STORAGE_CONTAINER; + }); + + describe("ConfigureAzure", () => { + it("should build the container client from the env vars", () => { + expect(BlobServiceClient).toHaveBeenCalledWith( + "https://test.blob.core.windows.net", + expect.anything(), + {}, + ); + expect(store).toBeDefined(); + }); + + it("should throw when AZURE_STORAGE_ACCOUNT_URL is missing", async () => { + delete process.env.AZURE_STORAGE_ACCOUNT_URL; + const f = Fastify(); + await expect( + f.register(FileStorePlugin, { type: "azureBlob" }), + ).rejects.toThrow("AZURE_STORAGE_ACCOUNT_URL is not defined"); + await f.close(); + }); + + it("should throw when AZURE_STORAGE_CONTAINER is missing", async () => { + delete process.env.AZURE_STORAGE_CONTAINER; + const f = Fastify(); + await expect( + f.register(FileStorePlugin, { type: "azureBlob" }), + ).rejects.toThrow("AZURE_STORAGE_CONTAINER is not defined"); + await f.close(); + }); + }); + + describe("exists", () => { + it("should return true when the blob exists", async () => { + mockBlobClient.exists.mockResolvedValueOnce(true); + await expect(store.exists("a.txt")).resolves.toBe(true); + expect(mockContainerClient.getBlobClient).toHaveBeenCalledWith("a.txt"); + }); + + it("should return false when the blob does not exist", async () => { + mockBlobClient.exists.mockResolvedValueOnce(false); + await expect(store.exists("a.txt")).resolves.toBe(false); + }); + }); + + describe("save", () => { + it("should encode a string payload as utf8", async () => { + mockBlobClient.uploadData.mockResolvedValueOnce({}); + + await store.save("a.txt", "text/plain", "content"); + + expect(mockBlobClient.uploadData).toHaveBeenCalledWith( + Buffer.from("content", "utf8"), + { blobHTTPHeaders: { blobContentType: "text/plain" } }, + ); + }); + + it("should pass a Buffer payload through untouched", async () => { + mockBlobClient.uploadData.mockResolvedValueOnce({}); + const data = Buffer.from([1, 2, 3]); + + await store.save("a.bin", "application/octet-stream", data); + + expect(mockBlobClient.uploadData.mock.calls[0][0]).toBe(data); + }); + + it("should throw when the response carries an errorCode", async () => { + mockBlobClient.uploadData.mockResolvedValueOnce({ + errorCode: "BadThing", + }); + await expect(store.save("a.txt", "text/plain", "x")).rejects.toThrow( + "BadThing", + ); + }); + }); + + describe("getAsBuffer", () => { + it("should collect the readable stream body", async () => { + mockBlobClient.download.mockResolvedValueOnce({ + readableStreamBody: Readable.from(["hello"]), + }); + + const buf = await store.getAsBuffer("a.txt"); + + expect(buf.toString()).toBe("hello"); + }); + + it("should throw when there is no readable stream body", async () => { + mockBlobClient.download.mockResolvedValueOnce({}); + await expect(store.getAsBuffer("a.txt")).rejects.toThrow( + "No readableStreamBody", + ); + }); + }); + + describe("copyFromLocalFile", () => { + it("should upload the local file", async () => { + mockBlobClient.uploadFile.mockResolvedValueOnce({}); + + await store.copyFromLocalFile("a.txt", "text/plain", "/tmp/a.txt"); + + expect(mockBlobClient.uploadFile).toHaveBeenCalledWith("/tmp/a.txt", { + blobHTTPHeaders: { blobContentType: "text/plain" }, + }); + }); + + it("should throw when the response carries an errorCode", async () => { + mockBlobClient.uploadFile.mockResolvedValueOnce({ errorCode: "Nope" }); + await expect( + store.copyFromLocalFile("a.txt", "text/plain", "/tmp/a.txt"), + ).rejects.toThrow("Nope"); + }); + }); + + describe("getAsStream", () => { + it("should return the readable stream body", async () => { + const body = Readable.from(["hello"]); + mockBlobClient.download.mockResolvedValueOnce({ + readableStreamBody: body, + }); + + await expect(store.getAsStream("a.txt")).resolves.toBe(body); + }); + + it("should throw when there is no readable stream body", async () => { + mockBlobClient.download.mockResolvedValueOnce({}); + await expect(store.getAsStream("a.txt")).rejects.toThrow( + "No readableStreamBody", + ); + }); + }); + + describe("copyFromStream", () => { + it("should upload the stream", async () => { + mockBlobClient.uploadStream.mockResolvedValueOnce({}); + const rs = Readable.from(["hello"]); + + await store.copyFromStream("a.txt", "text/plain", rs); + + expect(mockBlobClient.uploadStream).toHaveBeenCalledWith( + rs, + undefined, + undefined, + { blobHTTPHeaders: { blobContentType: "text/plain" } }, + ); + }); + + it("should throw when the response carries an errorCode", async () => { + mockBlobClient.uploadStream.mockResolvedValueOnce({ errorCode: "Nope" }); + await expect( + store.copyFromStream("a.txt", "text/plain", Readable.from(["x"])), + ).rejects.toThrow("Nope"); + }); + }); + + describe("getInfo", () => { + it("should map the blob properties", async () => { + mockBlobClient.getProperties.mockResolvedValueOnce({ + contentLength: 100, + contentType: "text/plain", + lastModified: new Date("2023-01-01"), + }); + + await expect(store.getInfo("a.txt")).resolves.toEqual({ + size: 100, + contentType: "text/plain", + lastModified: new Date("2023-01-01"), + }); + }); + + it("should fall back when the properties are absent", async () => { + mockBlobClient.getProperties.mockResolvedValueOnce({}); + + await expect(store.getInfo("a.txt")).resolves.toEqual({ + size: 0, + contentType: "application/octet-stream", + lastModified: new Date(0), + }); + }); + + it("should return null on a 404", async () => { + mockBlobClient.getProperties.mockRejectedValueOnce({ statusCode: 404 }); + await expect(store.getInfo("a.txt")).resolves.toBeNull(); + }); + + it("should rethrow a non-404 error", async () => { + const err = Object.assign(new Error("boom"), { statusCode: 500 }); + mockBlobClient.getProperties.mockRejectedValueOnce(err); + await expect(store.getInfo("a.txt")).rejects.toThrow("boom"); + }); + }); +}); diff --git a/src/file-store.gcs.spec.ts b/src/file-store.gcs.spec.ts new file mode 100644 index 0000000..e423b18 --- /dev/null +++ b/src/file-store.gcs.spec.ts @@ -0,0 +1,235 @@ +import { Readable, Writable } from "node:stream"; +import { Storage } from "@google-cloud/storage"; +import Fastify from "fastify"; +import FileStorePlugin, { FileStore } from "./file-store"; + +jest.mock("@google-cloud/storage"); + +describe("GCPFileStore", () => { + let fastify: ReturnType; + let mockFile: any; + let mockBucket: any; + let mockStorage: any; + let store: FileStore; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockFile = { + exists: jest.fn(), + save: jest.fn(), + getMetadata: jest.fn(), + createReadStream: jest.fn(), + createWriteStream: jest.fn(), + }; + + mockBucket = { + file: jest.fn(() => mockFile), + upload: jest.fn(), + }; + + mockStorage = { + bucket: jest.fn(() => mockBucket), + }; + + (Storage as unknown as jest.Mock).mockImplementation(() => mockStorage); + + process.env.STORAGE_BUCKET = "test-bucket"; + + fastify = Fastify(); + await fastify.register(FileStorePlugin, { type: "gcs" }); + store = fastify.FileStore; + }); + + afterEach(async () => { + delete process.env.STORAGE_BUCKET; + await fastify.close(); + }); + + describe("ConfigureGCP", () => { + it("should throw when STORAGE_BUCKET is not defined", async () => { + delete process.env.STORAGE_BUCKET; + const f = Fastify(); + await expect( + f.register(FileStorePlugin, { type: "gcs" }), + ).rejects.toThrow("STORAGE_BUCKET env-var is not defined"); + await f.close(); + }); + + it("should decorate the instance with a FileStore", () => { + expect(store).toBeDefined(); + expect(Storage).toHaveBeenCalled(); + }); + }); + + describe("exists", () => { + it("should return true when the file exists", async () => { + mockFile.exists.mockResolvedValueOnce([true]); + + expect(await store.exists("test.txt")).toBe(true); + expect(mockStorage.bucket).toHaveBeenCalledWith("test-bucket"); + expect(mockBucket.file).toHaveBeenCalledWith("test.txt"); + }); + + it("should return false when the file does not exist", async () => { + mockFile.exists.mockResolvedValueOnce([false]); + + expect(await store.exists("missing.txt")).toBe(false); + }); + }); + + describe("save", () => { + it("should save the data with the content type", async () => { + mockFile.save.mockResolvedValueOnce(undefined); + + await store.save("test.txt", "text/plain", "content"); + + expect(mockFile.save).toHaveBeenCalledWith("content", { + contentType: "text/plain", + }); + }); + }); + + describe("getAsBuffer", () => { + it("should collect the read stream into a buffer", async () => { + mockFile.createReadStream.mockReturnValueOnce( + Readable.from(["hello ", "world"]), + ); + + const buf = await store.getAsBuffer("test.txt"); + + expect(buf.toString()).toBe("hello world"); + }); + }); + + describe("copyFromLocalFile", () => { + it("should upload the local file to the destination", async () => { + mockBucket.upload.mockResolvedValueOnce(undefined); + + await store.copyFromLocalFile("dest.txt", "text/plain", "/tmp/src.txt"); + + expect(mockBucket.upload).toHaveBeenCalledWith("/tmp/src.txt", { + contentType: "text/plain", + destination: "dest.txt", + }); + }); + }); + + describe("getAsStream", () => { + it("should return the read stream", async () => { + const rs = Readable.from(["data"]); + mockFile.createReadStream.mockReturnValueOnce(rs); + + expect(await store.getAsStream("test.txt")).toBe(rs); + }); + }); + + describe("copyFromStream", () => { + it("should pipe the source stream into the gcs write stream", async () => { + const chunks: Buffer[] = []; + const ws = new Writable({ + write(chunk, _enc, cb) { + chunks.push(chunk); + cb(); + }, + }); + mockFile.createWriteStream.mockReturnValueOnce(ws); + + await store.copyFromStream( + "test.txt", + "text/plain", + Readable.from(["piped content"]), + ); + + expect(mockFile.createWriteStream).toHaveBeenCalledWith({ + resumable: false, + contentType: "text/plain", + }); + expect(Buffer.concat(chunks).toString()).toBe("piped content"); + }); + }); + + describe("getInfo", () => { + it("should use a numeric size as-is", async () => { + mockFile.getMetadata.mockResolvedValueOnce([ + { + size: 100, + contentType: "text/plain", + updated: "2023-01-01T00:00:00.000Z", + }, + ]); + + expect(await store.getInfo("test.txt")).toEqual({ + size: 100, + contentType: "text/plain", + lastModified: new Date("2023-01-01T00:00:00.000Z"), + }); + }); + + it("should parse a numeric string size", async () => { + mockFile.getMetadata.mockResolvedValueOnce([ + { + size: "512", + contentType: "application/pdf", + updated: "2024-06-01T12:00:00.000Z", + }, + ]); + + expect(await store.getInfo("test.pdf")).toEqual({ + size: 512, + contentType: "application/pdf", + lastModified: new Date("2024-06-01T12:00:00.000Z"), + }); + }); + + it("should default size to 0 when metadata.size is absent", async () => { + mockFile.getMetadata.mockResolvedValueOnce([ + { contentType: "text/plain", updated: "2023-01-01T00:00:00.000Z" }, + ]); + + const info = await store.getInfo("test.txt"); + + expect(info!.size).toBe(0); + }); + + it("should fall back to 0 when the size string is not a number", async () => { + mockFile.getMetadata.mockResolvedValueOnce([ + { + size: "not-a-number", + contentType: "text/plain", + updated: "2023-01-01T00:00:00.000Z", + }, + ]); + + const info = await store.getInfo("test.txt"); + + expect(info!.size).toBe(0); + }); + + it("should fall back to octet-stream and epoch when contentType and updated are absent", async () => { + mockFile.getMetadata.mockResolvedValueOnce([{ size: 10 }]); + + expect(await store.getInfo("test.bin")).toEqual({ + size: 10, + contentType: "application/octet-stream", + lastModified: new Date(0), + }); + }); + + it("should return null on a 404", async () => { + const err: any = new Error("Not Found"); + err.code = 404; + mockFile.getMetadata.mockRejectedValueOnce(err); + + expect(await store.getInfo("missing.txt")).toBeNull(); + }); + + it("should rethrow a non-404 error", async () => { + const err: any = new Error("boom"); + err.code = 500; + mockFile.getMetadata.mockRejectedValueOnce(err); + + await expect(store.getInfo("test.txt")).rejects.toThrow("boom"); + }); + }); +}); diff --git a/src/file-store.local.spec.ts b/src/file-store.local.spec.ts new file mode 100644 index 0000000..f798b75 --- /dev/null +++ b/src/file-store.local.spec.ts @@ -0,0 +1,241 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Readable } from "node:stream"; +import Fastify, { FastifyInstance } from "fastify"; +import FileStorePlugin, { FileStore } from "./file-store"; +import { streamToBuffer } from "./utils"; + +// LocalFileStore is not exported; reach it through the plugin decorator. +const storeOf = (f: FastifyInstance): FileStore & { dir: string } => + (f as any).FileStore; + +const defaultDir = path.join(os.tmpdir(), "stackboxwms"); + +describe("LocalFileStore", () => { + let fastify: FastifyInstance; + let tempDir: string; + let srcDir: string; + const envBefore = process.env.LOCAL_STORAGE_DIR; + + beforeEach(async () => { + fastify = Fastify({ logger: false }); + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "local-store-")); + srcDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "local-src-")); + process.env.LOCAL_STORAGE_DIR = tempDir; + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await fastify.close(); + await fs.promises.rm(tempDir, { recursive: true, force: true }); + await fs.promises.rm(srcDir, { recursive: true, force: true }); + if (envBefore === undefined) { + delete process.env.LOCAL_STORAGE_DIR; + } else { + process.env.LOCAL_STORAGE_DIR = envBefore; + } + }); + + const register = async (): Promise => { + await fastify.register(FileStorePlugin, { type: "local" }); + return storeOf(fastify); + }; + + describe("ConfigureLocal", () => { + it("uses LOCAL_STORAGE_DIR when set", async () => { + const store = await register(); + expect(store.dir).toBe(tempDir); + expect((await fs.promises.stat(tempDir)).isDirectory()).toBe(true); + }); + + it("falls back to os.tmpdir()/stackboxwms when LOCAL_STORAGE_DIR is unset", async () => { + delete process.env.LOCAL_STORAGE_DIR; + const store = await register(); + expect(store.dir).toBe(defaultDir); + expect((await fs.promises.stat(defaultDir)).isDirectory()).toBe(true); + }); + + it("falls back to os.tmpdir()/stackboxwms when LOCAL_STORAGE_DIR is empty", async () => { + process.env.LOCAL_STORAGE_DIR = ""; + const store = await register(); + expect(store.dir).toBe(defaultDir); + expect((await fs.promises.stat(defaultDir)).isDirectory()).toBe(true); + }); + }); + + describe("plugin dispatch", () => { + it("registers the local store", async () => { + await fastify.register(FileStorePlugin, { type: "local" }); + expect(storeOf(fastify)).toBeDefined(); + }); + + it("throws for an unknown storage type", async () => { + await expect( + fastify.register(FileStorePlugin, { type: "nope" as any }), + ).rejects.toThrow("Unknown storage type: nope"); + }); + }); + + describe("exists", () => { + it("returns true for an existing file", async () => { + const store = await register(); + await store.save("there.txt", "text/plain", "x"); + expect(await store.exists("there.txt")).toBe(true); + }); + }); + + describe("getInfo", () => { + it("returns size, content type and mtime", async () => { + const store = await register(); + await store.save("info.txt", "text/plain", "hello"); + const info = await store.getInfo("info.txt"); + expect(info?.size).toBe(5); + expect(info?.contentType).toBe("application/octet-stream"); + // fs.Stats dates come from another realm, so instanceof Date is false here + expect(info?.lastModified.getTime()).toBeGreaterThan(0); + }); + + it("returns null when the file does not exist", async () => { + const store = await register(); + expect(await store.getInfo("missing.txt")).toBeNull(); + }); + + it("rethrows non-ENOENT errors", async () => { + const store = await register(); + await store.save("a-file.txt", "text/plain", "x"); + // stat()ing through a regular file yields ENOTDIR, not ENOENT + await expect(store.getInfo("a-file.txt/child")).rejects.toMatchObject({ + code: "ENOTDIR", + }); + }); + }); + + describe("save", () => { + it("writes a string payload", async () => { + const store = await register(); + await store.save("str.txt", "text/plain", "string payload"); + expect( + await fs.promises.readFile(path.join(tempDir, "str.txt"), "utf8"), + ).toBe("string payload"); + }); + + it("writes a Buffer payload", async () => { + const store = await register(); + await store.save( + "buf.bin", + "application/octet-stream", + Buffer.from([1, 2, 3]), + ); + expect(await fs.promises.readFile(path.join(tempDir, "buf.bin"))).toEqual( + Buffer.from([1, 2, 3]), + ); + }); + }); + + describe("getAsBuffer", () => { + it("reads the file", async () => { + const store = await register(); + await store.save("read.txt", "text/plain", "buffered"); + expect((await store.getAsBuffer("read.txt")).toString()).toBe("buffered"); + }); + + it("throws File not found when stat yields nothing", async () => { + const store = await register(); + jest + .spyOn(fs.promises, "stat") + .mockResolvedValue(undefined as unknown as fs.Stats); + await expect(store.getAsBuffer("ghost.txt")).rejects.toThrow( + `File not found: ${path.join(tempDir, "ghost.txt")}`, + ); + }); + }); + + describe("getAsStream", () => { + it("streams the file", async () => { + const store = await register(); + await store.save("stream.txt", "text/plain", "streamed"); + const rs = await store.getAsStream("stream.txt"); + expect((await streamToBuffer(rs)).toString()).toBe("streamed"); + }); + + it("throws File not found when stat yields nothing", async () => { + const store = await register(); + jest + .spyOn(fs.promises, "stat") + .mockResolvedValue(undefined as unknown as fs.Stats); + await expect(store.getAsStream("ghost.txt")).rejects.toThrow( + `File not found: ${path.join(tempDir, "ghost.txt")}`, + ); + }); + }); + + describe("copyFromLocalFile", () => { + it("copies into a nested destination", async () => { + const store = await register(); + const src = path.join(srcDir, "src.txt"); + await fs.promises.writeFile(src, "copied"); + await store.copyFromLocalFile("a/b/c.txt", "text/plain", src); + expect( + await fs.promises.readFile(path.join(tempDir, "a/b/c.txt"), "utf8"), + ).toBe("copied"); + }); + }); + + describe("copyFromStream", () => { + it("pipes into a nested destination", async () => { + const store = await register(); + await store.copyFromStream( + "x/y/z.txt", + "text/plain", + Readable.from([Buffer.from("piped")]), + ); + expect( + await fs.promises.readFile(path.join(tempDir, "x/y/z.txt"), "utf8"), + ).toBe("piped"); + }); + }); +}); + +describe("streamToBuffer string chunks", () => { + it("converts a single string chunk", async () => { + const result = await streamToBuffer(Readable.from(["solo"])); + expect(result).toBeInstanceOf(Buffer); + expect(result.toString()).toBe("solo"); + }); + + it("converts multiple string chunks", async () => { + const result = await streamToBuffer(Readable.from(["one", "two"])); + expect(result.toString()).toBe("onetwo"); + }); + + it("rejects when the stream errors", async () => { + const boom = new Error("boom"); + const rs = new Readable({ + read() { + this.destroy(boom); + }, + }); + await expect(streamToBuffer(rs)).rejects.toThrow("boom"); + }); +}); + +describe("index barrel", () => { + afterEach(() => { + jest.dontMock("./file-store"); + }); + + it("re-exports the plugin", () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(require("./index").Plugins.FileStore).toBe(FileStorePlugin); + }); + + it("handles a file-store module without __esModule", () => { + jest.isolateModules(() => { + const cjs = { default: "cjs-plugin" }; + jest.doMock("./file-store", () => cjs); + // __importDefault wraps a non-ESM module, so .default is the module itself + expect(require("./index").Plugins.FileStore).toBe(cjs); + }); + }); +}); diff --git a/src/file-store.s3.spec.ts b/src/file-store.s3.spec.ts new file mode 100644 index 0000000..eee14c1 --- /dev/null +++ b/src/file-store.s3.spec.ts @@ -0,0 +1,312 @@ +import { Readable } from "node:stream"; +import * as S3 from "@aws-sdk/client-s3"; +import { defaultProvider } from "@aws-sdk/credential-provider-node"; +import { Upload } from "@aws-sdk/lib-storage"; +import Fastify from "fastify"; +import FileStorePlugin, { FileStore } from "./file-store"; + +jest.mock("@aws-sdk/client-s3"); +jest.mock("@aws-sdk/lib-storage"); +jest.mock("@aws-sdk/credential-provider-node"); + +const ORIGINAL_ENV = process.env; + +describe("S3FileStore", () => { + let mockSend: jest.Mock; + let mockDone: jest.Mock; + let fastify: ReturnType; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + fastify = Fastify(); + jest.clearAllMocks(); + + mockSend = jest.fn(); + (S3.S3Client as unknown as jest.Mock).mockImplementation(() => ({ + send: mockSend, + })); + + mockDone = jest.fn().mockResolvedValue(undefined); + (Upload as unknown as jest.Mock).mockImplementation(() => ({ + done: mockDone, + })); + }); + + afterEach(async () => { + await fastify.close(); + process.env = ORIGINAL_ENV; + }); + + const clientConfig = () => + (S3.S3Client as unknown as jest.Mock).mock.calls[0][0]; + + describe("ConfigureAWS", () => { + beforeEach(() => { + process.env.S3_BUCKET = "test-bucket"; + }); + + it("uses AWS_S3_REGION when set", async () => { + process.env.AWS_S3_REGION = "ap-south-1"; + + await fastify.register(FileStorePlugin, { type: "s3" }); + + expect(fastify.FileStore).toBeDefined(); + expect(clientConfig()).toEqual({ + region: "ap-south-1", + credentialDefaultProvider: defaultProvider, + }); + }); + + it("defaults the region to us-east-1 when AWS_S3_REGION is unset", async () => { + delete process.env.AWS_S3_REGION; + + await fastify.register(FileStorePlugin, { type: "s3" }); + + expect(clientConfig().region).toBe("us-east-1"); + }); + + it("throws when S3_BUCKET is not defined", async () => { + delete process.env.S3_BUCKET; + + await expect( + fastify.register(FileStorePlugin, { type: "s3" }), + ).rejects.toThrow("S3_BUCKET env-var is not defined"); + }); + }); + + describe("ConfigureMinio", () => { + beforeEach(() => { + process.env.MINIO_ENDPOINT = "http://localhost:9000"; + process.env.MINIO_ACCESS_KEY_ID = "minio-key"; + process.env.MINIO_SECRET_ACCESS_KEY = "minio-secret"; + process.env.MINIO_BUCKET = "minio-bucket"; + }); + + it.each([ + ["MINIO_ENDPOINT", "MINIO_ENDPOINT env-var is not defined"], + ["MINIO_ACCESS_KEY_ID", "MINIO_ACCESS_KEY_ID env-var is not defined"], + [ + "MINIO_SECRET_ACCESS_KEY", + "MINIO_SECRET_ACCESS_KEY env-var is not defined", + ], + ["MINIO_BUCKET", "MINIO_BUCKET env-var is not defined"], + ])("throws when %s is missing", async (envVar, message) => { + delete process.env[envVar]; + + await expect( + fastify.register(FileStorePlugin, { type: "minio" }), + ).rejects.toThrow(message); + }); + + it("uses MINIO_REGION when set", async () => { + process.env.MINIO_REGION = "eu-west-1"; + + await fastify.register(FileStorePlugin, { type: "minio" }); + + expect(fastify.FileStore).toBeDefined(); + expect(clientConfig()).toEqual({ + region: "eu-west-1", + endpoint: "http://localhost:9000", + credentials: { + accessKeyId: "minio-key", + secretAccessKey: "minio-secret", + }, + forcePathStyle: true, + }); + }); + + it("defaults the region to us-east-1 when MINIO_REGION is unset", async () => { + delete process.env.MINIO_REGION; + + await fastify.register(FileStorePlugin, { type: "minio" }); + + expect(clientConfig().region).toBe("us-east-1"); + }); + }); + + describe("FileStore operations", () => { + let store: FileStore; + + beforeEach(async () => { + process.env.S3_BUCKET = "test-bucket"; + await fastify.register(FileStorePlugin, { type: "s3" }); + store = fastify.FileStore; + }); + + describe("exists", () => { + it("returns true when the head request succeeds", async () => { + mockSend.mockResolvedValueOnce({}); + + await expect(store.exists("a/b.txt")).resolves.toBe(true); + expect(S3.HeadObjectCommand).toHaveBeenCalledWith({ + Bucket: "test-bucket", + Key: "a/b.txt", + }); + }); + + it("returns false on a NoSuchKey error", async () => { + mockSend.mockRejectedValueOnce(new S3.NoSuchKey({} as any)); + + await expect(store.exists("a/b.txt")).resolves.toBe(false); + }); + + it("returns false on a 404 $metadata status", async () => { + const err: any = new Error("Not Found"); + err["$metadata"] = { httpStatusCode: 404 }; + mockSend.mockRejectedValueOnce(err); + + await expect(store.exists("a/b.txt")).resolves.toBe(false); + }); + + it("rethrows any other error", async () => { + mockSend.mockRejectedValueOnce(new Error("boom")); + + await expect(store.exists("a/b.txt")).rejects.toThrow("boom"); + }); + }); + + describe("save", () => { + it("sends a PutObjectCommand", async () => { + mockSend.mockResolvedValueOnce({}); + + await store.save("a/b.txt", "text/plain", "content"); + + expect(S3.PutObjectCommand).toHaveBeenCalledWith({ + Bucket: "test-bucket", + Key: "a/b.txt", + Body: "content", + ContentType: "text/plain", + }); + expect(mockSend).toHaveBeenCalledWith(expect.any(S3.PutObjectCommand)); + }); + }); + + describe("getAsBuffer", () => { + it("buffers the response body", async () => { + mockSend.mockResolvedValueOnce({ Body: Readable.from(["hello"]) }); + + const buf = await store.getAsBuffer("a/b.txt"); + + expect(buf.toString()).toBe("hello"); + expect(S3.GetObjectCommand).toHaveBeenCalledWith({ + Bucket: "test-bucket", + Key: "a/b.txt", + }); + }); + + it("throws when the response has no Body", async () => { + mockSend.mockResolvedValueOnce({}); + + await expect(store.getAsBuffer("a/b.txt")).rejects.toThrow( + "No Body in response for a/b.txt", + ); + }); + }); + + describe("getAsStream", () => { + it("returns the response body", async () => { + const body = Readable.from(["hello"]); + mockSend.mockResolvedValueOnce({ Body: body }); + + await expect(store.getAsStream("a/b.txt")).resolves.toBe(body); + }); + + it("throws when the response has no Body", async () => { + mockSend.mockResolvedValueOnce({}); + + await expect(store.getAsStream("a/b.txt")).rejects.toThrow( + "No Body in response for a/b.txt", + ); + }); + }); + + describe("copyFromLocalFile", () => { + it("uploads a read stream of the local file", async () => { + await store.copyFromLocalFile("a/b.ts", "text/plain", __filename); + + const params = (Upload as unknown as jest.Mock).mock.calls[0][0]; + expect(params.params).toMatchObject({ + Bucket: "test-bucket", + Key: "a/b.ts", + ContentType: "text/plain", + }); + expect(params.params.Body.path).toBe(__filename); + expect(mockDone).toHaveBeenCalled(); + + params.params.Body.destroy(); + }); + }); + + describe("copyFromStream", () => { + it("uploads the given stream", async () => { + const rs = Readable.from(["hello"]); + + await store.copyFromStream("a/b.txt", "text/plain", rs); + + expect(Upload).toHaveBeenCalledWith({ + client: expect.anything(), + params: { + Bucket: "test-bucket", + Key: "a/b.txt", + Body: rs, + ContentType: "text/plain", + }, + }); + expect(mockDone).toHaveBeenCalled(); + }); + }); + + describe("getInfo", () => { + it("maps the head response", async () => { + const lastModified = new Date("2023-01-01T00:00:00.000Z"); + mockSend.mockResolvedValueOnce({ + ContentLength: 100, + ContentType: "text/plain", + LastModified: lastModified, + }); + + await expect(store.getInfo("a/b.txt")).resolves.toEqual({ + size: 100, + contentType: "text/plain", + lastModified, + }); + expect(S3.HeadObjectCommand).toHaveBeenCalledWith({ + Bucket: "test-bucket", + Key: "a/b.txt", + }); + }); + + it("falls back when the head response is missing fields", async () => { + mockSend.mockResolvedValueOnce({}); + + const info = await store.getInfo("a/b.txt"); + + expect(info).toMatchObject({ + size: 0, + contentType: "application/octet-stream", + }); + expect(info!.lastModified).toBeInstanceOf(Date); + }); + + it("returns null on a NoSuchKey error", async () => { + mockSend.mockRejectedValueOnce(new S3.NoSuchKey({} as any)); + + await expect(store.getInfo("a/b.txt")).resolves.toBeNull(); + }); + + it("returns null on a 404 $metadata status", async () => { + const err: any = new Error("Not Found"); + err["$metadata"] = { httpStatusCode: 404 }; + mockSend.mockRejectedValueOnce(err); + + await expect(store.getInfo("a/b.txt")).resolves.toBeNull(); + }); + + it("rethrows any other error", async () => { + mockSend.mockRejectedValueOnce(new Error("boom")); + + await expect(store.getInfo("a/b.txt")).rejects.toThrow("boom"); + }); + }); + }); +}); diff --git a/src/file-store.ts b/src/file-store.ts index 8943707..52aff2c 100644 --- a/src/file-store.ts +++ b/src/file-store.ts @@ -23,18 +23,20 @@ let AzureBlob!: typeof AzureBlobMod; let AzureIden!: typeof import("@azure/identity"); let Gcs!: typeof GcsMod; +// Plain assignment rather than ??=: node memoises require(), so re-assigning on a +// second call is free and keeps these branchless. function loadAWS(): void { - S3 ??= require("@aws-sdk/client-s3"); - Upload ??= require("@aws-sdk/lib-storage").Upload; + S3 = require("@aws-sdk/client-s3"); + Upload = require("@aws-sdk/lib-storage").Upload; } function loadAzure(): void { - AzureBlob ??= require("@azure/storage-blob"); - AzureIden ??= require("@azure/identity"); + AzureBlob = require("@azure/storage-blob"); + AzureIden = require("@azure/identity"); } function loadGCP(): void { - Gcs ??= require("@google-cloud/storage"); + Gcs = require("@google-cloud/storage"); } export interface FileInfo {