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.
 
 
 
 

911 lines
24 KiB

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { get, writable } from 'svelte/store';
import type { UserState } from '../../src/lib/stores/userStore';
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 = 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();
});
it('does not error when onCommentPosted is not provided', () => {
const onCommentPosted = undefined;
expect(() => {
if (onCommentPosted) {
onCommentPosted();
}
}).not.toThrow();
});
});
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);
});
});