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
This commit is contained in:
Claude
2026-01-29 09:25:22 +00:00
parent 78dcfe4edd
commit d1dc14c846
5 changed files with 465 additions and 58 deletions

View File

@@ -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],
);

View File

@@ -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<RelayPublishState[]>([]);

View File

@@ -0,0 +1,9 @@
/**
* Event builder functions for different Nostr event kinds
*/
export {
buildNoteDraft,
buildNoteEvent,
type BuildNoteOptions,
} from "./note-builder";

View File

@@ -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> = {}): 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);
});
});
});

View File

@@ -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<EventTemplate> {
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<NostrEvent> {
const factory = new EventFactory();
factory.setSigner(signer);
const draft = await buildNoteDraft(input, options);
// Sign and return the event
return factory.sign(draft);
}