mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
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:
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
9
src/lib/composers/index.ts
Normal file
9
src/lib/composers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Event builder functions for different Nostr event kinds
|
||||
*/
|
||||
|
||||
export {
|
||||
buildNoteDraft,
|
||||
buildNoteEvent,
|
||||
type BuildNoteOptions,
|
||||
} from "./note-builder";
|
||||
353
src/lib/composers/note-builder.test.ts
Normal file
353
src/lib/composers/note-builder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
src/lib/composers/note-builder.ts
Normal file
96
src/lib/composers/note-builder.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user