clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

906 lines
24 KiB

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<UserState>({
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<string[]>(["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<string[]>([]);
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<UserState>({
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 <html> 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);
});
});