Files
grimoire/src/lib/composers/note-builder.test.ts
Claude d1dc14c846 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
2026-01-29 09:25:22 +00:00

354 lines
11 KiB
TypeScript

/**
* 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);
});
});
});