From d1dc14c846c8e6a56ef2d87c9782b7c1a033a7fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 09:25:22 +0000 Subject: [PATCH] test: add tests for kind 1 event composition and cleanup - Add note-builder module with buildNoteDraft and buildNoteEvent functions - Add comprehensive tests for note event creation (21 tests): - Basic event structure (kind, content, timestamp) - Hashtag extraction from content - Custom emoji tags - Subject tag for title - Address references (a tags) - Client tag inclusion - Blob attachments (imeta tags) - Fix useRelaySelection to avoid object dependency in useMemo - Refactor PostViewer to use extracted note-builder https://claude.ai/code/session_01WpZc66saVdASHKrrnz3Tme --- src/components/PostViewer.tsx | 58 +--- src/hooks/useRelaySelection.ts | 7 +- src/lib/composers/index.ts | 9 + src/lib/composers/note-builder.test.ts | 353 +++++++++++++++++++++++++ src/lib/composers/note-builder.ts | 96 +++++++ 5 files changed, 465 insertions(+), 58 deletions(-) create mode 100644 src/lib/composers/index.ts create mode 100644 src/lib/composers/note-builder.test.ts create mode 100644 src/lib/composers/note-builder.ts diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index 6a81388..20aeb09 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -12,9 +12,7 @@ import { Kind1Renderer } from "@/components/nostr/kinds"; import { NOTE_SCHEMA } from "@/lib/composer/schemas"; import { useAccount } from "@/hooks/useAccount"; import { useSettings } from "@/hooks/useSettings"; -import { EventFactory } from "applesauce-core/event-factory"; -import { NoteBlueprint } from "applesauce-common/blueprints"; -import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; +import { buildNoteEvent } from "@/lib/composers/note-builder"; interface PostViewerProps { windowId?: string; @@ -31,59 +29,9 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { throw new Error("No signer available"); } - // Create event factory with signer - const factory = new EventFactory(); - factory.setSigner(signer); - - // Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content! - const draft = await factory.create(NoteBlueprint, input.content, { - emojis: input.emojiTags.map((e) => ({ - shortcode: e.shortcode, - url: e.url, - })), + return buildNoteEvent(input, signer, { + includeClientTag: settings?.post?.includeClientTag, }); - - // Add tags that applesauce doesn't handle yet - const additionalTags: string[][] = []; - - // Add subject tag if title provided - if (input.title) { - additionalTags.push(["subject", input.title]); - } - - // Add a tags for address references (naddr - not yet supported by applesauce) - for (const addr of input.addressRefs) { - additionalTags.push([ - "a", - `${addr.kind}:${addr.pubkey}:${addr.identifier}`, - ]); - } - - // Add client tag (if enabled) - if (settings?.post?.includeClientTag) { - additionalTags.push(GRIMOIRE_CLIENT_TAG); - } - - // Add imeta tags for blob attachments (NIP-92) - for (const blob of input.blobAttachments) { - const imetaTag = [ - "imeta", - `url ${blob.url}`, - `m ${blob.mimeType}`, - `x ${blob.sha256}`, - `size ${blob.size}`, - ]; - if (blob.server) { - imetaTag.push(`server ${blob.server}`); - } - additionalTags.push(imetaTag); - } - - // Merge additional tags with blueprint tags - draft.tags.push(...additionalTags); - - // Sign and return the event - return factory.sign(draft); }, [signer, settings?.post?.includeClientTag], ); diff --git a/src/hooks/useRelaySelection.ts b/src/hooks/useRelaySelection.ts index c710dd1..9cb3712 100644 --- a/src/hooks/useRelaySelection.ts +++ b/src/hooks/useRelaySelection.ts @@ -77,10 +77,11 @@ export function useRelaySelection( // Get relay pool state for connection status const relayPoolMap = use$(pool.relays$); + // Destructure options for stable dependency tracking + const { strategy, addressHints, contextRelay } = options; + // Determine write relays based on strategy const writeRelays = useMemo(() => { - const { strategy, addressHints, contextRelay } = options; - // Context-only strategy uses only the context relay if (strategy?.type === "context-only" && contextRelay) { return [contextRelay]; @@ -98,7 +99,7 @@ export function useRelaySelection( // Default: user-outbox or aggregator fallback return userWriteRelays.length > 0 ? userWriteRelays : AGGREGATOR_RELAYS; - }, [state.activeAccount?.relays, options]); + }, [state.activeAccount?.relays, strategy, addressHints, contextRelay]); // Relay states const [relayStates, setRelayStates] = useState([]); diff --git a/src/lib/composers/index.ts b/src/lib/composers/index.ts new file mode 100644 index 0000000..abb0ede --- /dev/null +++ b/src/lib/composers/index.ts @@ -0,0 +1,9 @@ +/** + * Event builder functions for different Nostr event kinds + */ + +export { + buildNoteDraft, + buildNoteEvent, + type BuildNoteOptions, +} from "./note-builder"; diff --git a/src/lib/composers/note-builder.test.ts b/src/lib/composers/note-builder.test.ts new file mode 100644 index 0000000..0e035b1 --- /dev/null +++ b/src/lib/composers/note-builder.test.ts @@ -0,0 +1,353 @@ +/** + * Tests for Kind 1 Note Event Builder + */ + +import { describe, it, expect } from "vitest"; +import { buildNoteDraft } from "./note-builder"; +import type { ComposerInput } from "@/components/composer"; +import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; + +// Helper to create minimal input +function createInput(overrides: Partial = {}): ComposerInput { + return { + content: "Hello, world!", + emojiTags: [], + blobAttachments: [], + addressRefs: [], + ...overrides, + }; +} + +describe("buildNoteDraft", () => { + describe("basic event structure", () => { + it("should create a kind 1 event", async () => { + const input = createInput(); + const draft = await buildNoteDraft(input); + + expect(draft.kind).toBe(1); + }); + + it("should include content in the event", async () => { + const input = createInput({ content: "Test content here" }); + const draft = await buildNoteDraft(input); + + expect(draft.content).toBe("Test content here"); + }); + + it("should have created_at timestamp", async () => { + const before = Math.floor(Date.now() / 1000); + const input = createInput(); + const draft = await buildNoteDraft(input); + const after = Math.floor(Date.now() / 1000) + 1; // +1 second buffer for timing + + expect(draft.created_at).toBeGreaterThanOrEqual(before); + expect(draft.created_at).toBeLessThanOrEqual(after); + }); + }); + + describe("hashtag extraction", () => { + it("should extract hashtags from content", async () => { + const input = createInput({ content: "Hello #nostr #test" }); + const draft = await buildNoteDraft(input); + + const tTags = draft.tags.filter((t) => t[0] === "t"); + expect(tTags).toContainEqual(["t", "nostr"]); + expect(tTags).toContainEqual(["t", "test"]); + }); + + it("should normalize hashtags to lowercase", async () => { + const input = createInput({ content: "Hello #NOSTR #Test" }); + const draft = await buildNoteDraft(input); + + const tTags = draft.tags.filter((t) => t[0] === "t"); + expect(tTags).toContainEqual(["t", "nostr"]); + expect(tTags).toContainEqual(["t", "test"]); + }); + }); + + describe("emoji tags", () => { + it("should include custom emoji tags", async () => { + const input = createInput({ + content: "Hello :wave:", + emojiTags: [{ shortcode: "wave", url: "https://example.com/wave.png" }], + }); + const draft = await buildNoteDraft(input); + + const emojiTags = draft.tags.filter((t) => t[0] === "emoji"); + expect(emojiTags).toContainEqual([ + "emoji", + "wave", + "https://example.com/wave.png", + ]); + }); + + it("should include multiple emoji tags", async () => { + const input = createInput({ + content: "Hello :wave: :smile:", + emojiTags: [ + { shortcode: "wave", url: "https://example.com/wave.png" }, + { shortcode: "smile", url: "https://example.com/smile.png" }, + ], + }); + const draft = await buildNoteDraft(input); + + const emojiTags = draft.tags.filter((t) => t[0] === "emoji"); + expect(emojiTags).toHaveLength(2); + }); + }); + + describe("subject tag (title)", () => { + it("should add subject tag when title is provided", async () => { + const input = createInput({ title: "My Post Title" }); + const draft = await buildNoteDraft(input); + + const subjectTag = draft.tags.find((t) => t[0] === "subject"); + expect(subjectTag).toEqual(["subject", "My Post Title"]); + }); + + it("should not add subject tag when title is undefined", async () => { + const input = createInput({ title: undefined }); + const draft = await buildNoteDraft(input); + + const subjectTag = draft.tags.find((t) => t[0] === "subject"); + expect(subjectTag).toBeUndefined(); + }); + + it("should not add subject tag when title is empty string", async () => { + const input = createInput({ title: "" }); + const draft = await buildNoteDraft(input); + + const subjectTag = draft.tags.find((t) => t[0] === "subject"); + expect(subjectTag).toBeUndefined(); + }); + }); + + describe("address references (a tags)", () => { + it("should add a tag for address references", async () => { + const input = createInput({ + addressRefs: [ + { + kind: 30023, + pubkey: "abc123pubkey", + identifier: "my-article", + }, + ], + }); + const draft = await buildNoteDraft(input); + + const aTags = draft.tags.filter((t) => t[0] === "a"); + expect(aTags).toContainEqual(["a", "30023:abc123pubkey:my-article"]); + }); + + it("should add multiple a tags for multiple references", async () => { + const input = createInput({ + addressRefs: [ + { kind: 30023, pubkey: "author1", identifier: "article-1" }, + { kind: 30818, pubkey: "author2", identifier: "wiki-page" }, + ], + }); + const draft = await buildNoteDraft(input); + + const aTags = draft.tags.filter((t) => t[0] === "a"); + expect(aTags).toHaveLength(2); + expect(aTags).toContainEqual(["a", "30023:author1:article-1"]); + expect(aTags).toContainEqual(["a", "30818:author2:wiki-page"]); + }); + }); + + describe("client tag", () => { + it("should include client tag when option is true", async () => { + const input = createInput(); + const draft = await buildNoteDraft(input, { includeClientTag: true }); + + const clientTag = draft.tags.find((t) => t[0] === "client"); + expect(clientTag).toEqual(GRIMOIRE_CLIENT_TAG); + }); + + it("should not include client tag when option is false", async () => { + const input = createInput(); + const draft = await buildNoteDraft(input, { includeClientTag: false }); + + const clientTag = draft.tags.find((t) => t[0] === "client"); + expect(clientTag).toBeUndefined(); + }); + + it("should not include client tag by default", async () => { + const input = createInput(); + const draft = await buildNoteDraft(input); + + const clientTag = draft.tags.find((t) => t[0] === "client"); + expect(clientTag).toBeUndefined(); + }); + }); + + describe("blob attachments (imeta tags)", () => { + it("should add imeta tag for blob attachment", async () => { + const input = createInput({ + blobAttachments: [ + { + url: "https://cdn.example.com/image.jpg", + sha256: "abc123hash", + mimeType: "image/jpeg", + size: 12345, + }, + ], + }); + const draft = await buildNoteDraft(input); + + const imetaTags = draft.tags.filter((t) => t[0] === "imeta"); + expect(imetaTags).toHaveLength(1); + expect(imetaTags[0]).toContain("imeta"); + expect(imetaTags[0]).toContain("url https://cdn.example.com/image.jpg"); + expect(imetaTags[0]).toContain("m image/jpeg"); + expect(imetaTags[0]).toContain("x abc123hash"); + expect(imetaTags[0]).toContain("size 12345"); + }); + + it("should include server in imeta tag when provided", async () => { + const input = createInput({ + blobAttachments: [ + { + url: "https://cdn.example.com/image.jpg", + sha256: "abc123hash", + mimeType: "image/jpeg", + size: 12345, + server: "https://blossom.example.com", + }, + ], + }); + const draft = await buildNoteDraft(input); + + const imetaTags = draft.tags.filter((t) => t[0] === "imeta"); + expect(imetaTags[0]).toContain("server https://blossom.example.com"); + }); + + it("should not include server in imeta tag when not provided", async () => { + const input = createInput({ + blobAttachments: [ + { + url: "https://cdn.example.com/image.jpg", + sha256: "abc123hash", + mimeType: "image/jpeg", + size: 12345, + }, + ], + }); + const draft = await buildNoteDraft(input); + + const imetaTags = draft.tags.filter((t) => t[0] === "imeta"); + const hasServer = imetaTags[0]?.some( + (item) => typeof item === "string" && item.startsWith("server "), + ); + expect(hasServer).toBe(false); + }); + + it("should add multiple imeta tags for multiple attachments", async () => { + const input = createInput({ + blobAttachments: [ + { + url: "https://cdn.example.com/image1.jpg", + sha256: "hash1", + mimeType: "image/jpeg", + size: 1000, + }, + { + url: "https://cdn.example.com/video.mp4", + sha256: "hash2", + mimeType: "video/mp4", + size: 50000, + }, + ], + }); + const draft = await buildNoteDraft(input); + + const imetaTags = draft.tags.filter((t) => t[0] === "imeta"); + expect(imetaTags).toHaveLength(2); + }); + }); + + describe("complex scenarios", () => { + it("should handle all features combined", async () => { + const input = createInput({ + content: "Check out this post #nostr :fire:", + title: "My Announcement", + emojiTags: [{ shortcode: "fire", url: "https://example.com/fire.gif" }], + blobAttachments: [ + { + url: "https://cdn.example.com/photo.jpg", + sha256: "photohash", + mimeType: "image/jpeg", + size: 5000, + server: "https://blossom.example.com", + }, + ], + addressRefs: [ + { + kind: 30023, + pubkey: "authorpub", + identifier: "referenced-article", + }, + ], + }); + const draft = await buildNoteDraft(input, { includeClientTag: true }); + + // Verify all components + expect(draft.kind).toBe(1); + expect(draft.content).toBe("Check out this post #nostr :fire:"); + + // Subject tag + expect(draft.tags.find((t) => t[0] === "subject")).toEqual([ + "subject", + "My Announcement", + ]); + + // Hashtag + expect(draft.tags.filter((t) => t[0] === "t")).toContainEqual([ + "t", + "nostr", + ]); + + // Emoji + expect(draft.tags.filter((t) => t[0] === "emoji")).toContainEqual([ + "emoji", + "fire", + "https://example.com/fire.gif", + ]); + + // Address reference + expect(draft.tags.filter((t) => t[0] === "a")).toContainEqual([ + "a", + "30023:authorpub:referenced-article", + ]); + + // Client tag + expect(draft.tags.find((t) => t[0] === "client")).toEqual( + GRIMOIRE_CLIENT_TAG, + ); + + // Imeta tag + const imetaTag = draft.tags.find((t) => t[0] === "imeta"); + expect(imetaTag).toBeDefined(); + expect(imetaTag).toContain("url https://cdn.example.com/photo.jpg"); + }); + + it("should handle empty content with attachments", async () => { + const input = createInput({ + content: "", + blobAttachments: [ + { + url: "https://cdn.example.com/image.jpg", + sha256: "hash", + mimeType: "image/jpeg", + size: 1000, + }, + ], + }); + const draft = await buildNoteDraft(input); + + expect(draft.kind).toBe(1); + expect(draft.content).toBe(""); + expect(draft.tags.filter((t) => t[0] === "imeta")).toHaveLength(1); + }); + }); +}); diff --git a/src/lib/composers/note-builder.ts b/src/lib/composers/note-builder.ts new file mode 100644 index 0000000..9b2b7f9 --- /dev/null +++ b/src/lib/composers/note-builder.ts @@ -0,0 +1,96 @@ +/** + * Kind 1 Note Event Builder + * + * Creates kind 1 (short text note) events from composer input. + * Separated from component for testability. + */ + +import type { NostrEvent } from "nostr-tools"; +import type { EventTemplate } from "nostr-tools/core"; +import type { ISigner } from "applesauce-signers"; +import { EventFactory } from "applesauce-core/event-factory"; +import { NoteBlueprint } from "applesauce-common/blueprints"; +import type { ComposerInput } from "@/components/composer"; +import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; + +export interface BuildNoteOptions { + /** Include client tag in the event */ + includeClientTag?: boolean; +} + +/** + * Build a kind 1 note draft (unsigned) + * Useful for testing the event structure without signing + */ +export async function buildNoteDraft( + input: ComposerInput, + options: BuildNoteOptions = {}, +): Promise { + const factory = new EventFactory(); + + // Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content! + const draft = await factory.create(NoteBlueprint, input.content, { + emojis: input.emojiTags.map((e) => ({ + shortcode: e.shortcode, + url: e.url, + })), + }); + + // Add tags that applesauce doesn't handle yet + const additionalTags: string[][] = []; + + // Add subject tag if title provided + if (input.title) { + additionalTags.push(["subject", input.title]); + } + + // Add a tags for address references (naddr - not yet supported by applesauce) + for (const addr of input.addressRefs) { + additionalTags.push([ + "a", + `${addr.kind}:${addr.pubkey}:${addr.identifier}`, + ]); + } + + // Add client tag (if enabled) + if (options.includeClientTag) { + additionalTags.push(GRIMOIRE_CLIENT_TAG); + } + + // Add imeta tags for blob attachments (NIP-92) + for (const blob of input.blobAttachments) { + const imetaTag = [ + "imeta", + `url ${blob.url}`, + `m ${blob.mimeType}`, + `x ${blob.sha256}`, + `size ${blob.size}`, + ]; + if (blob.server) { + imetaTag.push(`server ${blob.server}`); + } + additionalTags.push(imetaTag); + } + + // Merge additional tags with blueprint tags + draft.tags.push(...additionalTags); + + return draft; +} + +/** + * Build and sign a kind 1 note event + */ +export async function buildNoteEvent( + input: ComposerInput, + signer: ISigner, + options: BuildNoteOptions = {}, +): Promise { + const factory = new EventFactory(); + factory.setSigner(signer); + + const draft = await buildNoteDraft(input, options); + + // Sign and return the event + return factory.sign(draft); +}