mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
* Add Blossom blob storage integration - Add blossom-client-sdk dependency for blob storage operations - Create blossom.ts service with upload, list, check, mirror, delete primitives - Add kind 10063 server list fetching and parsing - Create blossom-parser.ts for command argument parsing with subcommands - Add BLOSSOM command to man.ts with subcommands: - servers: Show configured Blossom servers - check: Check server health - upload: Upload files to user's servers - list: List blobs for a user - mirror: Mirror blobs between servers - delete: Delete blobs from servers - Create BlossomViewer component with views for each subcommand - Wire up BlossomViewer in WindowRenderer - Add Blossom servers dropdown to ProfileViewer header - Upload primitives can be called programmatically for use in other components * Enhance Blossom viewer with server selection and blob details - Add server selection checkboxes to upload view for choosing target servers - Add BlobDetailView with media preview (image/video/audio) and metadata display - Add 'blob' subcommand to view individual blob details - Remove unused 'check' subcommand * Add Blossom upload dialog with chat integration - Create BlossomUploadDialog component with file picker, server selection, and preview - Create useBlossomUpload hook for easy integration in any component - Add insertText method to MentionEditor for programmatic text insertion - Integrate upload button (paperclip icon) in chat composer - Supports image, video, and audio uploads with drag-and-drop * Add rich blob attachments with imeta tags for chat - Add BlobAttachment TipTap extension with inline preview (thumbnail for images, icons for video/audio) - Store full blob metadata (sha256, url, mimeType, size, server) in editor nodes - Convert blob nodes to URLs in content with NIP-92 imeta tags when sending - Add insertBlob method to MentionEditor for programmatic blob insertion - Update NIP-29 and NIP-53 adapters to include imeta tags with blob metadata - Pass blob attachments through entire send flow (editor -> ChatViewer -> adapter) * Add fallback public Blossom servers for users without server list - Add well-known public servers as fallbacks (blossom.primal.net, nostr.download, files.v0l.io) - Use fallbacks when user has no kind 10063 server list configured - Show "Public Servers" label with Globe icon when using fallbacks - Inform user that no server list was found - Select first fallback server by default (vs all user servers) * Fix: Don't show fallback servers when not logged in Blossom uploads require signed auth events, so users must be logged in. The 'Account required' message is already shown in this case. * Remove files.v0l.io from fallback servers * Add rich renderer for kind 10063 Blossom server list - Create BlossomServerListRenderer.tsx with feed and detail views - Show user's configured Blossom servers with clickable links - Clicking a server opens the Blossom window with server info - Register renderers for kind 10063 (BUD-03) - Fix lint error by renaming useFallbackServers to applyFallbackServers * Add individual server view and NIP-05 support for blossom commands - Add 'server' subcommand to view info about a specific Blossom server - Update BlossomServerListRenderer to open server view on click - Make blossom parser async to support NIP-05 resolution in 'list' command - Add kind 10063 (Blossom Server List) to EVENT_KINDS constants with BUD-03 reference - Update command examples with NIP-05 identifier support * Add comprehensive tests for blossom-parser - 34 test cases covering all subcommands (servers, server, upload, list, blob, mirror, delete) - Tests for NIP-05 resolution, npub/nprofile decoding, $me alias - Tests for error handling and input validation - Tests for case insensitivity and command aliases (ls, view, rm) --------- Co-authored-by: Claude <noreply@anthropic.com>
286 lines
9.4 KiB
TypeScript
286 lines
9.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { parseBlossomCommand } from "./blossom-parser";
|
|
|
|
// Mock NIP-05 resolution
|
|
vi.mock("./nip05", () => ({
|
|
isNip05: (input: string) =>
|
|
input.includes("@") || /^[a-z0-9-]+\.[a-z]{2,}$/i.test(input),
|
|
resolveNip05: vi.fn(),
|
|
}));
|
|
|
|
import { resolveNip05 } from "./nip05";
|
|
const mockResolveNip05 = vi.mocked(resolveNip05);
|
|
|
|
describe("parseBlossomCommand", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("servers subcommand", () => {
|
|
it("should default to servers when no args provided", async () => {
|
|
const result = await parseBlossomCommand([]);
|
|
expect(result.subcommand).toBe("servers");
|
|
});
|
|
|
|
it("should parse explicit servers subcommand", async () => {
|
|
const result = await parseBlossomCommand(["servers"]);
|
|
expect(result.subcommand).toBe("servers");
|
|
});
|
|
});
|
|
|
|
describe("server subcommand", () => {
|
|
it("should parse server with URL", async () => {
|
|
const result = await parseBlossomCommand([
|
|
"server",
|
|
"https://blossom.primal.net",
|
|
]);
|
|
expect(result.subcommand).toBe("server");
|
|
expect(result.serverUrl).toBe("https://blossom.primal.net");
|
|
});
|
|
|
|
it("should normalize server URL without protocol", async () => {
|
|
const result = await parseBlossomCommand([
|
|
"server",
|
|
"blossom.primal.net",
|
|
]);
|
|
expect(result.serverUrl).toBe("https://blossom.primal.net");
|
|
});
|
|
|
|
it("should preserve http:// protocol", async () => {
|
|
const result = await parseBlossomCommand([
|
|
"server",
|
|
"http://localhost:3000",
|
|
]);
|
|
expect(result.serverUrl).toBe("http://localhost:3000");
|
|
});
|
|
|
|
it("should throw error when URL missing", async () => {
|
|
await expect(parseBlossomCommand(["server"])).rejects.toThrow(
|
|
"Server URL required",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("upload subcommand", () => {
|
|
it("should parse upload subcommand", async () => {
|
|
const result = await parseBlossomCommand(["upload"]);
|
|
expect(result.subcommand).toBe("upload");
|
|
});
|
|
});
|
|
|
|
describe("list subcommand", () => {
|
|
const testPubkey =
|
|
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
|
|
|
|
it("should parse list with no args (uses active account)", async () => {
|
|
const result = await parseBlossomCommand(["list"], testPubkey);
|
|
expect(result.subcommand).toBe("list");
|
|
expect(result.pubkey).toBe(testPubkey);
|
|
});
|
|
|
|
it("should parse list alias 'ls'", async () => {
|
|
const result = await parseBlossomCommand(["ls"], testPubkey);
|
|
expect(result.subcommand).toBe("list");
|
|
});
|
|
|
|
it("should parse list with hex pubkey", async () => {
|
|
const result = await parseBlossomCommand(["list", testPubkey]);
|
|
expect(result.pubkey).toBe(testPubkey);
|
|
});
|
|
|
|
it("should parse list with npub", async () => {
|
|
const npub =
|
|
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
|
|
const result = await parseBlossomCommand(["list", npub]);
|
|
expect(result.pubkey).toBe(testPubkey);
|
|
});
|
|
|
|
it("should parse list with $me alias", async () => {
|
|
const result = await parseBlossomCommand(["list", "$me"], testPubkey);
|
|
expect(result.pubkey).toBe(testPubkey);
|
|
});
|
|
|
|
it("should parse list with NIP-05 identifier", async () => {
|
|
const resolvedPubkey =
|
|
"0000000000000000000000000000000000000000000000000000000000000001";
|
|
mockResolveNip05.mockResolvedValueOnce(resolvedPubkey);
|
|
|
|
const result = await parseBlossomCommand(["list", "fiatjaf@fiatjaf.com"]);
|
|
expect(mockResolveNip05).toHaveBeenCalledWith("fiatjaf@fiatjaf.com");
|
|
expect(result.pubkey).toBe(resolvedPubkey);
|
|
});
|
|
|
|
it("should parse list with bare domain NIP-05", async () => {
|
|
const resolvedPubkey =
|
|
"0000000000000000000000000000000000000000000000000000000000000001";
|
|
mockResolveNip05.mockResolvedValueOnce(resolvedPubkey);
|
|
|
|
const result = await parseBlossomCommand(["list", "fiatjaf.com"]);
|
|
expect(mockResolveNip05).toHaveBeenCalledWith("fiatjaf.com");
|
|
expect(result.pubkey).toBe(resolvedPubkey);
|
|
});
|
|
|
|
it("should throw error for invalid pubkey format", async () => {
|
|
mockResolveNip05.mockResolvedValueOnce(null);
|
|
|
|
await expect(parseBlossomCommand(["list", "invalid"])).rejects.toThrow(
|
|
"Invalid pubkey format",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("blob subcommand", () => {
|
|
const validSha256 =
|
|
"b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553";
|
|
|
|
it("should parse blob with sha256", async () => {
|
|
const result = await parseBlossomCommand(["blob", validSha256]);
|
|
expect(result.subcommand).toBe("blob");
|
|
expect(result.sha256).toBe(validSha256);
|
|
expect(result.serverUrl).toBeUndefined();
|
|
});
|
|
|
|
it("should parse blob alias 'view'", async () => {
|
|
const result = await parseBlossomCommand(["view", validSha256]);
|
|
expect(result.subcommand).toBe("blob");
|
|
});
|
|
|
|
it("should parse blob with server URL", async () => {
|
|
const result = await parseBlossomCommand([
|
|
"blob",
|
|
validSha256,
|
|
"blossom.primal.net",
|
|
]);
|
|
expect(result.sha256).toBe(validSha256);
|
|
expect(result.serverUrl).toBe("https://blossom.primal.net");
|
|
});
|
|
|
|
it("should lowercase sha256", async () => {
|
|
const upperSha256 = validSha256.toUpperCase();
|
|
const result = await parseBlossomCommand(["blob", upperSha256]);
|
|
expect(result.sha256).toBe(validSha256);
|
|
});
|
|
|
|
it("should throw error when sha256 missing", async () => {
|
|
await expect(parseBlossomCommand(["blob"])).rejects.toThrow(
|
|
"SHA256 hash required",
|
|
);
|
|
});
|
|
|
|
it("should throw error for invalid sha256", async () => {
|
|
await expect(parseBlossomCommand(["blob", "invalid"])).rejects.toThrow(
|
|
"Invalid SHA256 hash",
|
|
);
|
|
});
|
|
|
|
it("should throw error for sha256 with wrong length", async () => {
|
|
await expect(parseBlossomCommand(["blob", "abc123"])).rejects.toThrow(
|
|
"Invalid SHA256 hash",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("mirror subcommand", () => {
|
|
it("should parse mirror with source and target", async () => {
|
|
const result = await parseBlossomCommand([
|
|
"mirror",
|
|
"https://source.com/blob",
|
|
"target.com",
|
|
]);
|
|
expect(result.subcommand).toBe("mirror");
|
|
expect(result.sourceUrl).toBe("https://source.com/blob");
|
|
expect(result.targetServer).toBe("https://target.com");
|
|
});
|
|
|
|
it("should throw error when source URL missing", async () => {
|
|
await expect(parseBlossomCommand(["mirror"])).rejects.toThrow(
|
|
"Source URL and target server required",
|
|
);
|
|
});
|
|
|
|
it("should throw error when target server missing", async () => {
|
|
await expect(
|
|
parseBlossomCommand(["mirror", "https://source.com/blob"]),
|
|
).rejects.toThrow("Source URL and target server required");
|
|
});
|
|
});
|
|
|
|
describe("delete subcommand", () => {
|
|
const validSha256 =
|
|
"b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553";
|
|
|
|
it("should parse delete with sha256 and server", async () => {
|
|
const result = await parseBlossomCommand([
|
|
"delete",
|
|
validSha256,
|
|
"blossom.primal.net",
|
|
]);
|
|
expect(result.subcommand).toBe("delete");
|
|
expect(result.sha256).toBe(validSha256);
|
|
expect(result.serverUrl).toBe("https://blossom.primal.net");
|
|
});
|
|
|
|
it("should parse delete alias 'rm'", async () => {
|
|
const result = await parseBlossomCommand([
|
|
"rm",
|
|
validSha256,
|
|
"server.com",
|
|
]);
|
|
expect(result.subcommand).toBe("delete");
|
|
});
|
|
|
|
it("should throw error when sha256 missing", async () => {
|
|
await expect(parseBlossomCommand(["delete"])).rejects.toThrow(
|
|
"SHA256 hash and server required",
|
|
);
|
|
});
|
|
|
|
it("should throw error when server missing", async () => {
|
|
await expect(
|
|
parseBlossomCommand(["delete", validSha256]),
|
|
).rejects.toThrow("SHA256 hash and server required");
|
|
});
|
|
|
|
it("should throw error for invalid sha256", async () => {
|
|
await expect(
|
|
parseBlossomCommand(["delete", "invalid", "server.com"]),
|
|
).rejects.toThrow("Invalid SHA256 hash");
|
|
});
|
|
});
|
|
|
|
describe("unknown subcommand", () => {
|
|
it("should throw error with help text for unknown subcommand", async () => {
|
|
await expect(parseBlossomCommand(["unknown"])).rejects.toThrow(
|
|
/Unknown subcommand: unknown/,
|
|
);
|
|
});
|
|
|
|
it("should include available subcommands in error", async () => {
|
|
try {
|
|
await parseBlossomCommand(["invalid"]);
|
|
} catch (e) {
|
|
const error = e as Error;
|
|
expect(error.message).toContain("servers");
|
|
expect(error.message).toContain("server <url>");
|
|
expect(error.message).toContain("upload");
|
|
expect(error.message).toContain("list");
|
|
expect(error.message).toContain("blob");
|
|
expect(error.message).toContain("mirror");
|
|
expect(error.message).toContain("delete");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("case insensitivity", () => {
|
|
it("should handle uppercase subcommands", async () => {
|
|
const result = await parseBlossomCommand(["SERVERS"]);
|
|
expect(result.subcommand).toBe("servers");
|
|
});
|
|
|
|
it("should handle mixed case subcommands", async () => {
|
|
const result = await parseBlossomCommand(["Upload"]);
|
|
expect(result.subcommand).toBe("upload");
|
|
});
|
|
});
|
|
});
|