import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { get, writable } from "svelte/store"; import type { UserState } from "../../src/lib/stores/userStore.ts"; import { NDKEvent } from "@nostr-dev-kit/ndk"; // Mock userStore const createMockUserStore = (signedIn: boolean = false) => { const store = writable({ pubkey: signedIn ? "a".repeat(64) : null, npub: signedIn ? "npub1test" : null, profile: signedIn ? { name: "Test User", displayName: "Test User", picture: "https://example.com/avatar.jpg", } : null, relays: { inbox: [], outbox: [] }, loginMethod: signedIn ? "extension" : null, ndkUser: null, signer: signedIn ? { sign: vi.fn() } as any : null, signedIn, }); return store; }; // Mock activeOutboxRelays const mockActiveOutboxRelays = writable(["wss://relay.example.com"]); // Mock NDK const createMockNDK = () => ({ fetchEvent: vi.fn(), publish: vi.fn(), }); describe("CommentButton - Address Parsing", () => { it("parses valid event address correctly", () => { const address = "30041:abc123def456:my-article"; const parts = address.split(":"); expect(parts).toHaveLength(3); const [kindStr, pubkey, dTag] = parts; const kind = parseInt(kindStr); expect(kind).toBe(30041); expect(pubkey).toBe("abc123def456"); expect(dTag).toBe("my-article"); expect(isNaN(kind)).toBe(false); }); it("handles dTag with colons correctly", () => { const address = "30041:abc123:article:with:colons"; const parts = address.split(":"); expect(parts.length).toBeGreaterThanOrEqual(3); const [kindStr, pubkey, ...dTagParts] = parts; const dTag = dTagParts.join(":"); expect(parseInt(kindStr)).toBe(30041); expect(pubkey).toBe("abc123"); expect(dTag).toBe("article:with:colons"); }); it("returns null for invalid address format (too few parts)", () => { const address = "30041:abc123"; const parts = address.split(":"); if (parts.length !== 3) { expect(parts.length).toBeLessThan(3); } }); it("returns null for invalid address format (invalid kind)", () => { const address = "invalid:abc123:dtag"; const parts = address.split(":"); const kind = parseInt(parts[0]); expect(isNaN(kind)).toBe(true); }); it("parses different publication kinds correctly", () => { const addresses = [ "30040:pubkey:section-id", // Zettel section "30041:pubkey:article-id", // Long-form article "30818:pubkey:wiki-id", // Wiki article "30023:pubkey:blog-id", // Blog post ]; addresses.forEach((address) => { const parts = address.split(":"); const kind = parseInt(parts[0]); expect(isNaN(kind)).toBe(false); expect(kind).toBeGreaterThan(0); }); }); }); describe("CommentButton - NIP-22 Event Creation", () => { let mockNDK: any; let mockUserStore: any; let mockActiveOutboxRelays: any; beforeEach(() => { mockNDK = createMockNDK(); mockUserStore = createMockUserStore(true); mockActiveOutboxRelays = writable(["wss://relay.example.com"]); }); afterEach(() => { vi.clearAllMocks(); }); it("creates kind 1111 comment event", async () => { const address = "30041:" + "a".repeat(64) + ":my-article"; const content = "This is my comment"; // Mock event creation const commentEvent = new NDKEvent(mockNDK); commentEvent.kind = 1111; commentEvent.content = content; expect(commentEvent.kind).toBe(1111); expect(commentEvent.content).toBe(content); }); it("includes correct uppercase tags (A, K, P) for root", () => { const address = "30041:" + "b".repeat(64) + ":article-id"; const authorPubkey = "b".repeat(64); const kind = 30041; const relayHint = "wss://relay.example.com"; const tags = [ ["A", address, relayHint, authorPubkey], ["K", kind.toString()], ["P", authorPubkey, relayHint], ]; // Verify uppercase root tags expect(tags[0][0]).toBe("A"); expect(tags[0][1]).toBe(address); expect(tags[0][2]).toBe(relayHint); expect(tags[0][3]).toBe(authorPubkey); expect(tags[1][0]).toBe("K"); expect(tags[1][1]).toBe(kind.toString()); expect(tags[2][0]).toBe("P"); expect(tags[2][1]).toBe(authorPubkey); expect(tags[2][2]).toBe(relayHint); }); it("includes correct lowercase tags (a, k, p) for parent", () => { const address = "30041:" + "c".repeat(64) + ":article-id"; const authorPubkey = "c".repeat(64); const kind = 30041; const relayHint = "wss://relay.example.com"; const tags = [ ["a", address, relayHint], ["k", kind.toString()], ["p", authorPubkey, relayHint], ]; // Verify lowercase parent tags expect(tags[0][0]).toBe("a"); expect(tags[0][1]).toBe(address); expect(tags[0][2]).toBe(relayHint); expect(tags[1][0]).toBe("k"); expect(tags[1][1]).toBe(kind.toString()); expect(tags[2][0]).toBe("p"); expect(tags[2][1]).toBe(authorPubkey); expect(tags[2][2]).toBe(relayHint); }); it("includes e tag with event ID when available", () => { const eventId = "d".repeat(64); const relayHint = "wss://relay.example.com"; const eTag = ["e", eventId, relayHint]; expect(eTag[0]).toBe("e"); expect(eTag[1]).toBe(eventId); expect(eTag[2]).toBe(relayHint); expect(eTag[1]).toHaveLength(64); }); it("creates complete NIP-22 tag structure", () => { const address = "30041:" + "e".repeat(64) + ":test-article"; const authorPubkey = "e".repeat(64); const kind = 30041; const eventId = "f".repeat(64); const relayHint = "wss://relay.example.com"; const tags = [ // Root scope - uppercase tags ["A", address, relayHint, authorPubkey], ["K", kind.toString()], ["P", authorPubkey, relayHint], // Parent scope - lowercase tags ["a", address, relayHint], ["k", kind.toString()], ["p", authorPubkey, relayHint], // Event ID ["e", eventId, relayHint], ]; // Verify all tags are present expect(tags).toHaveLength(7); // Verify root tags expect(tags.filter((t) => t[0] === "A")).toHaveLength(1); expect(tags.filter((t) => t[0] === "K")).toHaveLength(1); expect(tags.filter((t) => t[0] === "P")).toHaveLength(1); // Verify parent tags expect(tags.filter((t) => t[0] === "a")).toHaveLength(1); expect(tags.filter((t) => t[0] === "k")).toHaveLength(1); expect(tags.filter((t) => t[0] === "p")).toHaveLength(1); // Verify event tag expect(tags.filter((t) => t[0] === "e")).toHaveLength(1); }); it("uses correct relay hints from activeOutboxRelays", () => { const relays: string[] = get(mockActiveOutboxRelays); const relayHint = relays[0]; expect(relayHint).toBe("wss://relay.example.com"); expect(relays).toHaveLength(1); }); it("handles multiple outbox relays correctly", () => { const multipleRelays = writable([ "wss://relay1.example.com", "wss://relay2.example.com", "wss://relay3.example.com", ]); const relays = get(multipleRelays); const relayHint = relays[0]; expect(relayHint).toBe("wss://relay1.example.com"); expect(relays).toHaveLength(3); }); it("handles empty relay list gracefully", () => { const emptyRelays = writable([]); const relays = get(emptyRelays); const relayHint = relays[0] || ""; expect(relayHint).toBe(""); }); }); describe("CommentButton - Event Signing and Publishing", () => { let mockNDK: any; let mockSigner: any; beforeEach(() => { mockNDK = createMockNDK(); mockSigner = { sign: vi.fn().mockResolvedValue(undefined), }; }); afterEach(() => { vi.clearAllMocks(); }); it("signs event with user signer", async () => { const commentEvent = new NDKEvent(mockNDK); commentEvent.kind = 1111; commentEvent.content = "Test comment"; await mockSigner.sign(commentEvent); expect(mockSigner.sign).toHaveBeenCalledWith(commentEvent); expect(mockSigner.sign).toHaveBeenCalledTimes(1); }); it("publishes to outbox relays", async () => { const publishMock = vi.fn().mockResolvedValue( new Set(["wss://relay.example.com"]), ); const commentEvent = new NDKEvent(mockNDK); commentEvent.publish = publishMock; const publishedRelays = await commentEvent.publish(); expect(publishMock).toHaveBeenCalled(); expect(publishedRelays.size).toBeGreaterThan(0); }); it("handles publishing errors gracefully", async () => { const publishMock = vi.fn().mockResolvedValue(new Set()); const commentEvent = new NDKEvent(mockNDK); commentEvent.publish = publishMock; const publishedRelays = await commentEvent.publish(); expect(publishedRelays.size).toBe(0); }); it("throws error when publishing fails", async () => { const publishMock = vi.fn().mockRejectedValue(new Error("Network error")); const commentEvent = new NDKEvent(mockNDK); commentEvent.publish = publishMock; await expect(commentEvent.publish()).rejects.toThrow("Network error"); }); }); describe("CommentButton - User Authentication", () => { it("requires user to be signed in", () => { const signedOutStore = createMockUserStore(false); const user = get(signedOutStore); expect(user.signedIn).toBe(false); expect(user.signer).toBeNull(); }); it("shows error when user is not signed in", () => { const signedOutStore = createMockUserStore(false); const user = get(signedOutStore); if (!user.signedIn || !user.signer) { const error = "You must be signed in to comment"; expect(error).toBe("You must be signed in to comment"); } }); it("allows commenting when user is signed in", () => { const signedInStore = createMockUserStore(true); const user = get(signedInStore); expect(user.signedIn).toBe(true); expect(user.signer).not.toBeNull(); }); it("displays user profile information when signed in", () => { const signedInStore = createMockUserStore(true); const user = get(signedInStore); expect(user.profile).not.toBeNull(); expect(user.profile?.displayName).toBe("Test User"); expect(user.profile?.picture).toBe("https://example.com/avatar.jpg"); }); it("handles missing user profile gracefully", () => { const storeWithoutProfile = writable({ pubkey: "a".repeat(64), npub: "npub1test", profile: null, relays: { inbox: [], outbox: [] }, loginMethod: "extension", ndkUser: null, signer: { sign: vi.fn() } as any, signedIn: true, }); const user = get(storeWithoutProfile); const displayName = user.profile?.displayName || user.profile?.name || "Anonymous"; expect(displayName).toBe("Anonymous"); }); }); describe("CommentButton - User Interactions", () => { it("prevents submission of empty comment", () => { const commentContent = ""; const isEmpty = !commentContent.trim(); expect(isEmpty).toBe(true); }); it("allows submission of non-empty comment", () => { const commentContent = "This is a valid comment"; const isEmpty = !commentContent.trim(); expect(isEmpty).toBe(false); }); it("handles whitespace-only comments as empty", () => { const commentContent = " \n\t "; const isEmpty = !commentContent.trim(); expect(isEmpty).toBe(true); }); it("clears input after successful comment", () => { let commentContent = "This is my comment"; // Simulate successful submission commentContent = ""; expect(commentContent).toBe(""); }); it("closes comment UI after successful posting", () => { let showCommentUI = true; // Simulate successful post with delay setTimeout(() => { showCommentUI = false; }, 0); // Initially still open expect(showCommentUI).toBe(true); }); it("calls onCommentPosted callback when provided", () => { const onCommentPosted = vi.fn(); // Simulate successful comment post onCommentPosted(); expect(onCommentPosted).toHaveBeenCalled(); }); }); describe("CommentButton - UI State Management", () => { it("button is hidden by default", () => { const sectionHovered = false; const showCommentUI = false; const visible = sectionHovered || showCommentUI; expect(visible).toBe(false); }); it("button appears on section hover", () => { const sectionHovered = true; const showCommentUI = false; const visible = sectionHovered || showCommentUI; expect(visible).toBe(true); }); it("button remains visible when comment UI is shown", () => { const sectionHovered = false; const showCommentUI = true; const visible = sectionHovered || showCommentUI; expect(visible).toBe(true); }); it("toggles comment UI when button is clicked", () => { let showCommentUI = false; // Simulate button click showCommentUI = !showCommentUI; expect(showCommentUI).toBe(true); // Click again showCommentUI = !showCommentUI; expect(showCommentUI).toBe(false); }); it("resets error state when toggling UI", () => { let error: string | null = "Previous error"; let success = true; // Simulate UI toggle error = null; success = false; expect(error).toBeNull(); expect(success).toBe(false); }); it("shows error message when present", () => { const error = "Failed to post comment"; expect(error).toBeDefined(); expect(error.length).toBeGreaterThan(0); }); it("shows success message after posting", () => { const success = true; const successMessage = "Comment posted successfully!"; if (success) { expect(successMessage).toBe("Comment posted successfully!"); } }); it("disables submit button when submitting", () => { const isSubmitting = true; const disabled = isSubmitting; expect(disabled).toBe(true); }); it("disables submit button when comment is empty", () => { const commentContent = ""; const isSubmitting = false; const disabled = isSubmitting || !commentContent.trim(); expect(disabled).toBe(true); }); it("enables submit button when comment is valid", () => { const commentContent = "Valid comment"; const isSubmitting = false; const disabled = isSubmitting || !commentContent.trim(); expect(disabled).toBe(false); }); }); describe("CommentButton - Edge Cases", () => { it("handles invalid address format gracefully", () => { const invalidAddresses = [ "", "invalid", "30041:", ":pubkey:dtag", "30041:pubkey", "not-a-number:pubkey:dtag", ]; invalidAddresses.forEach((address) => { const parts = address.split(":"); const isValid = parts.length === 3 && !isNaN(parseInt(parts[0])); expect(isValid).toBe(false); }); }); it("handles network errors during event fetch", async () => { const mockNDK = { fetchEvent: vi.fn().mockRejectedValue(new Error("Network error")), }; let eventId = ""; try { await mockNDK.fetchEvent({}); } catch (err) { // Handle gracefully, continue without event ID eventId = ""; } expect(eventId).toBe(""); }); it("handles missing relay information", () => { const emptyRelays: string[] = []; const relayHint = emptyRelays[0] || ""; expect(relayHint).toBe(""); }); it("handles very long comment text without truncation", () => { const longComment = "a".repeat(10000); const content = longComment; expect(content.length).toBe(10000); expect(content).toBe(longComment); }); it("handles special characters in comments", () => { const specialComments = [ 'Comment with "quotes"', "Comment with emoji 😊", "Comment with\nnewlines", "Comment with\ttabs", "Comment with tags", "Comment with & ampersands", ]; specialComments.forEach((comment) => { expect(comment.length).toBeGreaterThan(0); expect(typeof comment).toBe("string"); }); }); it("handles event creation failure", async () => { const address = "invalid:address"; const parts = address.split(":"); if (parts.length !== 3) { const error = "Invalid event address"; expect(error).toBe("Invalid event address"); } }); it("handles signing errors", async () => { const mockSigner = { sign: vi.fn().mockRejectedValue(new Error("Signing failed")), }; const event = { kind: 1111, content: "test" }; await expect(mockSigner.sign(event)).rejects.toThrow("Signing failed"); }); it("handles publish failure when no relays accept event", async () => { const publishMock = vi.fn().mockResolvedValue(new Set()); const relaySet = await publishMock(); if (relaySet.size === 0) { const error = "Failed to publish to any relays"; expect(error).toBe("Failed to publish to any relays"); } }); }); describe("CommentButton - Cancel Functionality", () => { it("clears comment content when canceling", () => { let commentContent = "This comment will be canceled"; // Simulate cancel commentContent = ""; expect(commentContent).toBe(""); }); it("closes comment UI when canceling", () => { let showCommentUI = true; // Simulate cancel showCommentUI = false; expect(showCommentUI).toBe(false); }); it("clears error state when canceling", () => { let error: string | null = "Some error"; // Simulate cancel error = null; expect(error).toBeNull(); }); it("clears success state when canceling", () => { let success = true; // Simulate cancel success = false; expect(success).toBe(false); }); }); describe("CommentButton - Event Fetching", () => { let mockNDK: any; beforeEach(() => { mockNDK = createMockNDK(); }); afterEach(() => { vi.clearAllMocks(); }); it("fetches target event to get event ID", async () => { const address = "30041:" + "a".repeat(64) + ":article"; const parts = address.split(":"); const [kindStr, authorPubkey, dTag] = parts; const kind = parseInt(kindStr); const mockEvent = { id: "b".repeat(64), kind, pubkey: authorPubkey, tags: [["d", dTag]], }; mockNDK.fetchEvent.mockResolvedValue(mockEvent); const targetEvent = await mockNDK.fetchEvent({ kinds: [kind], authors: [authorPubkey], "#d": [dTag], }); expect(mockNDK.fetchEvent).toHaveBeenCalled(); expect(targetEvent?.id).toBe("b".repeat(64)); }); it("continues without event ID when fetch fails", async () => { mockNDK.fetchEvent.mockRejectedValue(new Error("Fetch failed")); let eventId = ""; try { const targetEvent = await mockNDK.fetchEvent({}); if (targetEvent) { eventId = targetEvent.id; } } catch (err) { // Continue without event ID eventId = ""; } expect(eventId).toBe(""); }); it("handles null event from fetch", async () => { mockNDK.fetchEvent.mockResolvedValue(null); const targetEvent = await mockNDK.fetchEvent({}); let eventId = ""; if (targetEvent) { eventId = targetEvent.id; } expect(eventId).toBe(""); }); }); describe("CommentButton - CSS Classes and Styling", () => { it("applies visible class when section is hovered", () => { const sectionHovered = true; const showCommentUI = false; const hasVisibleClass = sectionHovered || showCommentUI; expect(hasVisibleClass).toBe(true); }); it("removes visible class when not hovered and UI closed", () => { const sectionHovered = false; const showCommentUI = false; const hasVisibleClass = sectionHovered || showCommentUI; expect(hasVisibleClass).toBe(false); }); it("button has correct aria-label", () => { const ariaLabel = "Add comment"; expect(ariaLabel).toBe("Add comment"); }); it("button has correct title attribute", () => { const title = "Add comment"; expect(title).toBe("Add comment"); }); it("submit button shows loading state when submitting", () => { const isSubmitting = true; const buttonText = isSubmitting ? "Posting..." : "Post Comment"; expect(buttonText).toBe("Posting..."); }); it("submit button shows normal state when not submitting", () => { const isSubmitting = false; const buttonText = isSubmitting ? "Posting..." : "Post Comment"; expect(buttonText).toBe("Post Comment"); }); }); describe("CommentButton - NIP-22 Compliance", () => { it("uses kind 1111 for comment events", () => { const kind = 1111; expect(kind).toBe(1111); }); it("includes all required NIP-22 tags for addressable events", () => { const requiredRootTags = ["A", "K", "P"]; const requiredParentTags = ["a", "k", "p"]; const tags = [ ["A", "address", "relay", "pubkey"], ["K", "kind"], ["P", "pubkey", "relay"], ["a", "address", "relay"], ["k", "kind"], ["p", "pubkey", "relay"], ]; requiredRootTags.forEach((tag) => { expect(tags.some((t) => t[0] === tag)).toBe(true); }); requiredParentTags.forEach((tag) => { expect(tags.some((t) => t[0] === tag)).toBe(true); }); }); it("A tag includes relay hint and author pubkey", () => { const aTag = ["A", "30041:pubkey:dtag", "wss://relay.com", "pubkey"]; expect(aTag).toHaveLength(4); expect(aTag[0]).toBe("A"); expect(aTag[2]).toMatch(/^wss:\/\//); expect(aTag[3]).toBeTruthy(); }); it("P tag includes relay hint", () => { const pTag = ["P", "pubkey", "wss://relay.com"]; expect(pTag).toHaveLength(3); expect(pTag[0]).toBe("P"); expect(pTag[2]).toMatch(/^wss:\/\//); }); it("lowercase tags for parent scope match root tags", () => { const address = "30041:pubkey:dtag"; const kind = "30041"; const pubkey = "pubkey"; const relay = "wss://relay.com"; const rootTags = [ ["A", address, relay, pubkey], ["K", kind], ["P", pubkey, relay], ]; const parentTags = [ ["a", address, relay], ["k", kind], ["p", pubkey, relay], ]; // Verify parent tags match root tags (lowercase) expect(parentTags[0][1]).toBe(rootTags[0][1]); // address expect(parentTags[1][1]).toBe(rootTags[1][1]); // kind expect(parentTags[2][1]).toBe(rootTags[2][1]); // pubkey }); }); describe("CommentButton - Integration Scenarios", () => { it("complete comment flow for signed-in user", () => { const userStore = createMockUserStore(true); const user = get(userStore); // User is signed in expect(user.signedIn).toBe(true); // Comment content is valid const content = "Great article!"; expect(content.trim().length).toBeGreaterThan(0); // Address is valid const address = "30041:" + "a".repeat(64) + ":article"; const parts = address.split(":"); expect(parts.length).toBe(3); // Event would be created with kind 1111 const kind = 1111; expect(kind).toBe(1111); }); it("prevents comment flow for signed-out user", () => { const userStore = createMockUserStore(false); const user = get(userStore); expect(user.signedIn).toBe(false); if (!user.signedIn) { const error = "You must be signed in to comment"; expect(error).toBeTruthy(); } }); it("handles comment with event ID lookup", async () => { const mockNDK = createMockNDK(); const eventId = "c".repeat(64); mockNDK.fetchEvent.mockResolvedValue({ id: eventId }); const targetEvent = await mockNDK.fetchEvent({}); const tags = [ ["e", targetEvent.id, "wss://relay.com"], ]; expect(tags[0][1]).toBe(eventId); }); it("handles comment without event ID lookup", () => { const eventId = ""; const tags = [ ["A", "address", "relay", "pubkey"], ["K", "kind"], ["P", "pubkey", "relay"], ["a", "address", "relay"], ["k", "kind"], ["p", "pubkey", "relay"], ]; // No e tag should be included expect(tags.filter((t) => t[0] === "e")).toHaveLength(0); // But all other required tags should be present expect(tags.length).toBe(6); }); });