Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions lib/filebase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

// Mock @aws-sdk/client-s3 before importing filebase module
vi.mock("@aws-sdk/client-s3", () => {
const sendMock = vi.fn();
return {
S3Client: vi.fn(() => ({ send: sendMock })),
PutObjectCommand: vi.fn((args: unknown) => ({
_type: "PutObject",
...Object(args),
})),
HeadObjectCommand: vi.fn((args: unknown) => ({
_type: "HeadObject",
...Object(args),
})),
__sendMock: sendMock,
};
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sendMock: any;

beforeEach(async () => {
vi.unstubAllEnvs();
vi.stubEnv("FILEBASE_ACCESS_KEY", "test-key");
vi.stubEnv("FILEBASE_SECRET_KEY", "test-secret");
vi.stubEnv("FILEBASE_BUCKET", "test-bucket");

const s3Module = await import("@aws-sdk/client-s3");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendMock = (s3Module as any).__sendMock;
sendMock.mockReset();
});

import { getFilebaseClient, uploadToIPFS, uploadWithRetry } from "./filebase";

// ---------------------------------------------------------------------------
// getFilebaseClient
// ---------------------------------------------------------------------------

describe("getFilebaseClient", () => {
it("throws when FILEBASE_ACCESS_KEY is missing", () => {
vi.stubEnv("FILEBASE_ACCESS_KEY", "");
expect(() => getFilebaseClient()).toThrow("Missing FILEBASE_ACCESS_KEY");
});

it("throws when FILEBASE_SECRET_KEY is missing", () => {
vi.stubEnv("FILEBASE_SECRET_KEY", "");
expect(() => getFilebaseClient()).toThrow("Missing FILEBASE_ACCESS_KEY or FILEBASE_SECRET_KEY");
});

it("returns an S3Client when credentials are set", () => {
const client = getFilebaseClient();
expect(client).toBeDefined();
expect(client.send).toBeDefined();
});
});

// ---------------------------------------------------------------------------
// uploadToIPFS
// ---------------------------------------------------------------------------

describe("uploadToIPFS", () => {
it("throws when FILEBASE_BUCKET is missing", async () => {
vi.stubEnv("FILEBASE_BUCKET", "");
await expect(uploadToIPFS("content", "key.txt")).rejects.toThrow(
"Missing FILEBASE_BUCKET"
);
});

it("uploads content and returns CID from HeadObject metadata", async () => {
// PutObject succeeds, HeadObject returns CID
sendMock
.mockResolvedValueOnce({}) // PutObjectCommand
.mockResolvedValueOnce({ Metadata: { cid: "QmTestCid123" } }); // HeadObjectCommand

const cid = await uploadToIPFS("chapter content", "plots/1-0.txt");
expect(cid).toBe("QmTestCid123");
expect(sendMock).toHaveBeenCalledTimes(2);
});

it("throws when HeadObject response has no CID", async () => {
sendMock
.mockResolvedValueOnce({}) // PutObjectCommand
.mockResolvedValueOnce({ Metadata: {} }); // HeadObjectCommand — no cid

await expect(uploadToIPFS("content", "key.txt")).rejects.toThrow(
"Filebase response missing CID"
);
});
});

// ---------------------------------------------------------------------------
// uploadWithRetry
// ---------------------------------------------------------------------------

describe("uploadWithRetry", () => {
it("returns CID on first success", async () => {
sendMock
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ Metadata: { cid: "QmFirst" } });

const cid = await uploadWithRetry("content", "key.txt");
expect(cid).toBe("QmFirst");
});

it("retries on failure and succeeds on second attempt", async () => {
// First attempt: PutObject fails
sendMock
.mockRejectedValueOnce(new Error("Network error"))
// Second attempt: succeeds
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ Metadata: { cid: "QmRetry" } });

const cid = await uploadWithRetry("content", "key.txt", 3);
expect(cid).toBe("QmRetry");
});

it("throws after exhausting all retries", async () => {
sendMock.mockRejectedValue(new Error("Persistent failure"));

await expect(
uploadWithRetry("content", "key.txt", 2)
).rejects.toThrow("Persistent failure");
});
});
93 changes: 93 additions & 0 deletions lib/filebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
S3Client,
PutObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";

/**
* Create an S3-compatible client for Filebase IPFS pinning.
*
* Requires env vars: FILEBASE_ACCESS_KEY, FILEBASE_SECRET_KEY.
*/
export function getFilebaseClient(): S3Client {
const accessKey = process.env.FILEBASE_ACCESS_KEY;
const secretKey = process.env.FILEBASE_SECRET_KEY;
if (!accessKey || !secretKey) {
throw new Error(
"Filebase not configured: Missing FILEBASE_ACCESS_KEY or FILEBASE_SECRET_KEY"
);
}
return new S3Client({
endpoint: "https://s3.filebase.com",
region: "us-east-1",
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
});
}

/**
* Upload content to Filebase (IPFS pinning via S3 API) and return the CID.
*
* The CID is retrieved from the HeadObject response metadata after upload.
* Content is stored as UTF-8 plain text.
*
* @param content - The text content to upload
* @param key - S3 object key (e.g. "plotlink/plots/42-0.txt")
* @returns The IPFS CID string
*/
export async function uploadToIPFS(
content: string,
key: string
): Promise<string> {
const bucket = process.env.FILEBASE_BUCKET;
if (!bucket) {
throw new Error("Filebase not configured: Missing FILEBASE_BUCKET");
}

const s3 = getFilebaseClient();

await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: content,
ContentType: "text/plain; charset=utf-8",
})
);

const head = await s3.send(
new HeadObjectCommand({ Bucket: bucket, Key: key })
);
const cid = head.Metadata?.cid;
if (!cid) {
throw new Error("Filebase response missing CID in metadata");
}

return cid;
}

/**
* Upload content to IPFS with retry logic.
*
* 3 attempts with exponential backoff (1s, 2s, 4s).
* Same pattern as dropcast's upload retry.
*/
export async function uploadWithRetry(
content: string,
key: string,
maxRetries = 3
): Promise<string> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await uploadToIPFS(content, key);
} catch (error) {
lastError = error instanceof Error ? error : new Error("Unknown error");
if (attempt < maxRetries) {
await new Promise((r) =>
setTimeout(r, Math.pow(2, attempt - 1) * 1000)
);
}
}
}
throw lastError || new Error("IPFS upload failed after retries");
}
Loading
Loading