@julesl23/s5js
Version:
Enhanced TypeScript SDK for S5 decentralized storage with path-based API, media processing, and directory utilities
578 lines • 24.4 kB
JavaScript
// test/fs/phase2-comprehensive-mocked.test.ts
import { describe, test, expect, beforeEach } from "vitest";
import { FS5 } from "../../src/fs/fs5.js";
import { JSCryptoImplementation } from "../../src/api/crypto/js.js";
// Mock S5 API for comprehensive testing
class MockS5API {
crypto;
blobs = new Map();
registry = new Map();
constructor() {
this.crypto = new JSCryptoImplementation();
}
async uploadBlob(blob) {
const data = new Uint8Array(await blob.arrayBuffer());
const hash = await this.crypto.hashBlake3(data);
const fullHash = new Uint8Array([0x1e, ...hash]);
const key = Buffer.from(hash).toString('hex');
this.blobs.set(key, data);
return { hash: fullHash, size: blob.size };
}
async downloadBlobAsBytes(hash) {
const actualHash = hash[0] === 0x1e ? hash.slice(1) : hash;
const key = Buffer.from(actualHash).toString('hex');
const data = this.blobs.get(key);
if (!data)
throw new Error(`Blob not found: ${key}`);
return data;
}
async registryGet(publicKey) {
const key = Buffer.from(publicKey).toString('hex');
return this.registry.get(key);
}
async registrySet(entry) {
const key = Buffer.from(entry.pk).toString('hex');
this.registry.set(key, entry);
}
}
// Mock identity
class MockIdentity {
fsRootKey = new Uint8Array(32).fill(42);
}
// Extended FS5 with mocked directory operations
// @ts-ignore - overriding private methods for testing
class MockedFS5 extends FS5 {
directories = new Map();
writeKeys = new Map();
constructor(api, identity) {
super(api, identity);
this.initializeRoot();
}
initializeRoot() {
// Create root directory
const rootDir = {
magic: "S5.pro",
header: {},
dirs: new Map([
["home", this.createDirRef()],
["archive", this.createDirRef()]
]),
files: new Map()
};
this.directories.set('', rootDir);
this.writeKeys.set('', new Uint8Array(32).fill(1));
// Create home and archive directories
const emptyDir = {
magic: "S5.pro",
header: {},
dirs: new Map(),
files: new Map()
};
this.directories.set('home', { ...emptyDir });
this.directories.set('archive', { ...emptyDir });
}
createDirRef() {
return {
link: {
type: 'fixed_hash_blake3',
hash: new Uint8Array(32).fill(0)
},
ts_seconds: Math.floor(Date.now() / 1000)
};
}
// Override _loadDirectory to use our mock
// @ts-ignore - accessing private method for testing
async _loadDirectory(path) {
return this.directories.get(path);
}
// Override _updateDirectory to use our mock
// @ts-ignore - accessing private method for testing
async _updateDirectory(path, updater) {
// Ensure parent directories exist
const segments = path.split('/').filter(s => s);
let currentPath = '';
for (let i = 0; i < segments.length; i++) {
const parentPath = currentPath;
currentPath = segments.slice(0, i + 1).join('/');
if (!this.directories.has(currentPath)) {
// Create directory
const newDir = {
magic: "S5.pro",
header: {},
dirs: new Map(),
files: new Map()
};
this.directories.set(currentPath, newDir);
// Update parent
const parent = this.directories.get(parentPath);
if (parent) {
parent.dirs.set(segments[i], this.createDirRef());
}
}
}
// Now update the target directory
const dir = this.directories.get(path) || {
magic: "S5.pro",
header: {},
dirs: new Map(),
files: new Map()
};
const writeKey = this.writeKeys.get(path) || new Uint8Array(32).fill(1);
const updated = await updater(dir, writeKey);
if (updated) {
this.directories.set(path, updated);
}
}
// Override createDirectory
async createDirectory(parentPath, name) {
const fullPath = parentPath ? `${parentPath}/${name}` : name;
if (!this.directories.has(fullPath)) {
const newDir = {
magic: "S5.pro",
header: {},
dirs: new Map(),
files: new Map()
};
this.directories.set(fullPath, newDir);
// Update parent
const parent = this.directories.get(parentPath || '');
if (parent) {
const dirRef = this.createDirRef();
parent.dirs.set(name, dirRef);
return dirRef;
}
}
return this.createDirRef();
}
// Override to avoid permission issues
async ensureIdentityInitialized() {
// Already initialized in constructor
}
}
describe("Phase 2 - Comprehensive Tests", () => {
let fs;
let api;
beforeEach(async () => {
api = new MockS5API();
const identity = new MockIdentity();
fs = new MockedFS5(api, identity);
});
describe("Unicode and Special Characters", () => {
test("handles Chinese characters in paths", async () => {
const chinesePath = "home/文档/我的文件.txt";
const content = "Hello 你好";
await fs.put(chinesePath, content);
const retrieved = await fs.get(chinesePath);
expect(retrieved).toBe(content);
// Verify it appears in listing
const items = [];
for await (const item of fs.list("home/文档")) {
items.push(item);
}
expect(items).toHaveLength(1);
expect(items[0].name).toBe("我的文件.txt");
});
test("handles Japanese characters in filenames", async () => {
const files = [
"home/docs/ファイル.txt",
"home/docs/ドキュメント.json",
"home/docs/画像.png"
];
for (const path of files) {
await fs.put(path, `Content of ${path}`);
}
const items = [];
for await (const item of fs.list("home/docs")) {
items.push(item);
}
expect(items).toHaveLength(3);
expect(items.map(i => i.name)).toContain("ファイル.txt");
});
test("handles emoji in filenames", async () => {
const emojiFiles = [
"home/emoji/🚀rocket.txt",
"home/emoji/❤️heart.json",
"home/emoji/🎉party🎊.md"
];
for (const path of emojiFiles) {
await fs.put(path, "emoji content");
}
// Test retrieval
const content = await fs.get("home/emoji/🚀rocket.txt");
expect(content).toBe("emoji content");
// Test listing
const items = [];
for await (const item of fs.list("home/emoji")) {
items.push(item);
}
expect(items).toHaveLength(3);
});
test("handles RTL text (Arabic/Hebrew) in paths", async () => {
const arabicPath = "home/مستندات/ملف.txt";
const hebrewPath = "home/מסמכים/קובץ.txt";
await fs.put(arabicPath, "Arabic content مرحبا");
await fs.put(hebrewPath, "Hebrew content שלום");
expect(await fs.get(arabicPath)).toBe("Arabic content مرحبا");
expect(await fs.get(hebrewPath)).toBe("Hebrew content שלום");
});
test("handles special characters in filenames", async () => {
const specialFiles = [
"home/special/file@email.txt",
"home/special/report#1.pdf",
"home/special/data$money.json",
"home/special/test%percent.md",
"home/special/doc&report.txt",
"home/special/file(1).txt",
"home/special/file[bracket].txt",
"home/special/file{brace}.txt"
];
for (const path of specialFiles) {
await fs.put(path, `Content: ${path}`);
}
// Verify all files can be retrieved
for (const path of specialFiles) {
const content = await fs.get(path);
// PDF files should return as binary
if (path.endsWith('.pdf')) {
expect(content).toBeInstanceOf(Uint8Array);
// Verify the content is correct by decoding it
const text = new TextDecoder().decode(content);
expect(text).toBe(`Content: ${path}`);
}
else {
expect(content).toBe(`Content: ${path}`);
}
}
// Check listing
const items = [];
for await (const item of fs.list("home/special")) {
items.push(item);
}
expect(items).toHaveLength(specialFiles.length);
});
test("handles files with spaces in names", async () => {
const spacedFiles = [
"home/spaced/my file.txt",
"home/spaced/another file.txt", // double space
"home/spaced/ leading.txt",
"home/spaced/trailing .txt"
];
for (const path of spacedFiles) {
await fs.put(path, "spaced content");
}
for (const path of spacedFiles) {
expect(await fs.get(path)).toBe("spaced content");
}
});
test("handles mixed character sets in single path", async () => {
const mixedPath = "home/mixed/Hello世界_مرحبا_שלום🌍.txt";
await fs.put(mixedPath, "Global content");
expect(await fs.get(mixedPath)).toBe("Global content");
const metadata = await fs.getMetadata(mixedPath);
expect(metadata?.name).toBe("Hello世界_مرحبا_שלום🌍.txt");
});
});
describe("Path Resolution Edge Cases", () => {
test("handles paths with multiple consecutive slashes", async () => {
const paths = [
"home///documents///file.txt",
"home//test//nested//deep.json",
"//home/files//data.bin"
];
for (const messyPath of paths) {
await fs.put(messyPath, "content");
// Should be accessible via normalized path
const normalizedPath = messyPath.replace(/\/+/g, '/').replace(/^\//, '');
const content = await fs.get(normalizedPath);
// .bin files should return as binary
if (normalizedPath.endsWith('.bin')) {
expect(content).toBeInstanceOf(Uint8Array);
// Verify the content is correct by decoding it
const text = new TextDecoder().decode(content);
expect(text).toBe("content");
}
else {
expect(content).toBe("content");
}
}
});
test("handles paths with trailing slashes", async () => {
await fs.put("home/trail/file.txt", "trailing test");
// Directory paths with trailing slash
const items1 = [];
for await (const item of fs.list("home/trail/")) {
items1.push(item);
}
const items2 = [];
for await (const item of fs.list("home/trail")) {
items2.push(item);
}
expect(items1).toHaveLength(items2.length);
expect(items1[0]?.name).toBe(items2[0]?.name);
});
test("handles dots in filenames and paths", async () => {
const dotFiles = [
"home/dots/.hidden",
"home/dots/..doubledot",
"home/dots/file.tar.gz",
"home/dots/file...multiple.dots"
];
for (const path of dotFiles) {
await fs.put(path, "dot content");
}
const items = [];
for await (const item of fs.list("home/dots")) {
items.push(item.name);
}
expect(items).toContain(".hidden");
expect(items).toContain("..doubledot");
expect(items).toContain("file.tar.gz");
expect(items).toContain("file...multiple.dots");
});
test("preserves case sensitivity", async () => {
const casePaths = [
"home/case/File.txt",
"home/case/file.txt",
"home/case/FILE.txt",
"home/case/FiLe.txt"
];
// Store different content in each
for (let i = 0; i < casePaths.length; i++) {
await fs.put(casePaths[i], `Content ${i}`);
}
// Verify each has unique content
for (let i = 0; i < casePaths.length; i++) {
const content = await fs.get(casePaths[i]);
expect(content).toBe(`Content ${i}`);
}
// List should show all variants
const items = [];
for await (const item of fs.list("home/case")) {
items.push(item.name);
}
expect(items).toHaveLength(4);
expect(new Set(items).size).toBe(4);
});
});
describe("Error Handling and Edge Cases", () => {
test("handles non-existent parent directories gracefully", async () => {
const result = await fs.get("home/does/not/exist/file.txt");
expect(result).toBeUndefined();
const metadata = await fs.getMetadata("home/does/not/exist");
expect(metadata).toBeUndefined();
const deleted = await fs.delete("home/does/not/exist/file.txt");
expect(deleted).toBe(false);
});
test("handles empty string paths appropriately", async () => {
// Empty path should list root
const rootItems = [];
for await (const item of fs.list("")) {
rootItems.push(item.name);
}
expect(rootItems).toContain("home");
expect(rootItems).toContain("archive");
});
test("handles null and undefined data gracefully", async () => {
// These should be converted to empty strings
await fs.put("home/null.txt", null);
await fs.put("home/undefined.txt", undefined);
const content1 = await fs.get("home/null.txt");
expect(content1).toBe('');
const content2 = await fs.get("home/undefined.txt");
expect(content2).toBe('');
});
test("handles corrupted cursor gracefully", async () => {
// Create some files
for (let i = 0; i < 10; i++) {
await fs.put(`home/corrupt-test/file${i}.txt`, `content${i}`);
}
const corruptedCursors = [
"not-base64!@#$",
btoa("invalid-cbor-data"),
btoa(JSON.stringify({ wrong: "format" })),
"SGVsbG8gV29ybGQ", // Valid base64 but not cursor data
];
for (const badCursor of corruptedCursors) {
let error;
try {
const items = [];
for await (const item of fs.list("home/corrupt-test", { cursor: badCursor })) {
items.push(item);
}
}
catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error?.message).toContain("cursor");
}
});
});
describe("Data Type Handling", () => {
test("correctly handles various object types", async () => {
const testObjects = [
{ simple: "object" },
{ nested: { deep: { value: 42 } } },
{ array: [1, 2, 3, 4, 5] },
{ mixed: { str: "hello", num: 123, bool: true, nil: null } },
{ date: new Date().toISOString() },
{ unicode: { text: "Hello 世界 🌍" } },
{ empty: {} },
{ bigNumber: 9007199254740991 }, // MAX_SAFE_INTEGER
];
for (let i = 0; i < testObjects.length; i++) {
const path = `home/objects/test${i}.json`;
await fs.put(path, testObjects[i]);
const retrieved = await fs.get(path);
expect(retrieved).toEqual(testObjects[i]);
}
});
test("handles binary data of various sizes", async () => {
const sizes = [0, 1, 100, 1024, 65536]; // Skip 1MB for speed
for (const size of sizes) {
const data = new Uint8Array(size);
// Fill with pattern
for (let i = 0; i < size; i++) {
data[i] = i % 256;
}
const path = `home/binary/size_${size}.bin`;
await fs.put(path, data);
const retrieved = await fs.get(path);
expect(retrieved).toBeInstanceOf(Uint8Array);
expect(new Uint8Array(retrieved)).toEqual(data);
}
});
test("preserves data types through round trips", async () => {
const typeTests = [
{ path: "home/types/string.txt", data: "plain string", expectedType: "string" },
{ path: "home/types/number.json", data: { value: 42 }, expectedType: "object" },
{ path: "home/types/binary.bin", data: new Uint8Array([1, 2, 3]), expectedType: "Uint8Array" },
{ path: "home/types/boolean.json", data: { flag: true }, expectedType: "object" },
{ path: "home/types/array.json", data: [1, "two", { three: 3 }], expectedType: "object" },
];
for (const test of typeTests) {
await fs.put(test.path, test.data);
const retrieved = await fs.get(test.path);
if (test.expectedType === "Uint8Array") {
expect(retrieved).toBeInstanceOf(Uint8Array);
}
else if (test.expectedType === "object") {
expect(typeof retrieved).toBe("object");
expect(retrieved).toEqual(test.data);
}
else {
expect(typeof retrieved).toBe(test.expectedType);
}
}
});
});
describe("Media Type and Metadata", () => {
test("correctly infers media types from extensions", async () => {
const files = [
{ path: "home/media/doc.pdf", expectedType: "application/pdf" },
{ path: "home/media/image.jpg", expectedType: "image/jpeg" },
{ path: "home/media/image.jpeg", expectedType: "image/jpeg" },
{ path: "home/media/image.png", expectedType: "image/png" },
{ path: "home/media/page.html", expectedType: "text/html" },
{ path: "home/media/style.css", expectedType: "text/css" },
{ path: "home/media/script.js", expectedType: "application/javascript" },
{ path: "home/media/data.json", expectedType: "application/json" },
{ path: "home/media/video.mp4", expectedType: "video/mp4" },
{ path: "home/media/audio.mp3", expectedType: "audio/mpeg" },
{ path: "home/media/archive.zip", expectedType: "application/zip" },
];
for (const file of files) {
await fs.put(file.path, "dummy content");
const metadata = await fs.getMetadata(file.path);
expect(metadata?.mediaType).toBe(file.expectedType);
}
});
test("preserves custom timestamps", async () => {
const timestamps = [
Date.now() - 86400000 * 365, // 1 year ago
Date.now() - 86400000 * 30, // 30 days ago
Date.now() - 3600000, // 1 hour ago
Date.now(), // now
Date.now() + 3600000, // 1 hour future
];
for (let i = 0; i < timestamps.length; i++) {
await fs.put(`home/timestamps/file${i}.txt`, "content", {
timestamp: timestamps[i]
});
const metadata = await fs.getMetadata(`home/timestamps/file${i}.txt`);
// S5 stores timestamps in seconds, so we lose millisecond precision
// We need to compare at second precision
const expectedTimestamp = new Date(Math.floor(timestamps[i] / 1000) * 1000).toISOString();
expect(metadata?.timestamp).toBe(expectedTimestamp);
}
});
test("handles files with no extension", async () => {
const noExtFiles = [
"home/noext/README",
"home/noext/Makefile",
"home/noext/LICENSE",
"home/noext/CHANGELOG"
];
for (const path of noExtFiles) {
await fs.put(path, "content without extension");
const metadata = await fs.getMetadata(path);
expect(metadata).toBeDefined();
expect(metadata?.name).toBe(path.split('/').pop());
}
});
});
describe("Cursor Pagination", () => {
test("handles cursor at exact page boundaries", async () => {
// Create exactly 30 files
for (let i = 0; i < 30; i++) {
await fs.put(`home/boundaries/file_${i.toString().padStart(2, '0')}.txt`, `${i}`);
}
// Get pages of exactly 10 items
const pages = [];
let cursor;
for (let page = 0; page < 3; page++) {
const pageItems = [];
for await (const item of fs.list("home/boundaries", { cursor, limit: 10 })) {
pageItems.push(item.name);
cursor = item.cursor;
}
pages.push(pageItems);
}
expect(pages[0]).toHaveLength(10);
expect(pages[1]).toHaveLength(10);
expect(pages[2]).toHaveLength(10);
// Verify no duplicates across pages
const allItems = pages.flat();
expect(new Set(allItems).size).toBe(30);
});
test("cursor remains valid after new files added", async () => {
// Create initial files
for (let i = 0; i < 10; i++) {
await fs.put(`home/dynamic/initial_${i}.txt`, `Initial ${i}`);
}
// Get cursor at position 5
let cursor;
let count = 0;
for await (const item of fs.list("home/dynamic")) {
if (count === 5) {
cursor = item.cursor;
break;
}
count++;
}
expect(cursor).toBeDefined();
// Add new files that sort after cursor position
for (let i = 0; i < 5; i++) {
await fs.put(`home/dynamic/new_${i}.txt`, `New ${i}`);
}
// Resume from cursor - should see remaining initials plus new files
const remainingItems = [];
for await (const item of fs.list("home/dynamic", { cursor })) {
remainingItems.push(item.name);
}
expect(remainingItems.length).toBeGreaterThanOrEqual(9); // 4 initial + 5 new
expect(remainingItems).toContain("new_0.txt");
});
});
});
//# sourceMappingURL=phase2-comprehensive-mocked.test.js.map