// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fetchFeeds, fetchItems, renderFeeds, renderItems, toggleStar, toggleRead } from './app.js'; // Mock fetch const fetchMock = vi.fn(); global.fetch = fetchMock; describe('Vanilla JS App', () => { beforeEach(() => { document.body.innerHTML = `

All Items

`; fetchMock.mockReset(); }); describe('fetchFeeds', () => { it('should fetch feeds and render them', async () => { const mockFeeds = [{ id: 1, title: 'Test Feed', url: 'http://example.com' }]; fetchMock.mockResolvedValue({ ok: true, json: async () => mockFeeds, }); await fetchFeeds(); expect(fetchMock).toHaveBeenCalledWith('/api/feed/'); const feedItems = document.querySelectorAll('.feed-item'); // "All Items", "Unread Items", "Starred Items", plus 1 feed = 4 items expect(feedItems.length).toBe(4); expect(feedItems[3].textContent).toBe('Test Feed'); }); it('should handle errors gracefully', async () => { fetchMock.mockRejectedValue(new Error('Network error')); await expect(fetchFeeds()).rejects.toThrow('Network error'); expect(document.getElementById('feeds-nav').innerHTML).toContain('Error loading feeds'); }); }); describe('fetchItems', () => { it('should fetch items and render them', async () => { const mockItems = [{ id: 101, title: 'Item 1', url: 'http://example.com/1', feed: { title: 'Feed A' }, starred: false, read: false }]; fetchMock.mockResolvedValue({ ok: true, json: async () => mockItems, }); await fetchItems(); expect(fetchMock).toHaveBeenCalledWith('/api/stream/'); const entries = document.querySelectorAll('.entry'); expect(entries.length).toBe(1); expect(entries[0].querySelector('.entry-title').textContent).toBe('Item 1'); }); it('should handle filters', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); await fetchItems(123, 'unread', 'query'); const expectedUrl = '/api/stream/?feed_id=123&read_filter=unread&q=query'; expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('feed_id=123')); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('read_filter=unread')); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=query')); }); }); describe('renderFeeds', () => { it('should render system feeds and user feeds', async () => { const feeds = [ { id: 1, title: 'Feed 1', url: 'u1' }, { id: 2, title: 'Feed 2', url: 'u2' } ]; // Mock fetch for the click handler fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); renderFeeds(feeds); const items = document.querySelectorAll('.feed-item'); expect(items.length).toBe(5); // All, Unread, Starred, Feed 1, Feed 2 // Click handler test: All Items items[0].click(); expect(items[0].classList.contains('active')).toBe(true); expect(document.getElementById('feed-title').textContent).toBe('All Items'); expect(fetchMock).toHaveBeenCalledWith('/api/stream/'); // Click handler test: Unread Items items[1].click(); expect(items[1].classList.contains('active')).toBe(true); expect(document.getElementById('feed-title').textContent).toBe('Unread Items'); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('read_filter=unread')); // Click handler test: Starred Items items[2].click(); expect(items[2].classList.contains('active')).toBe(true); expect(document.getElementById('feed-title').textContent).toBe('Starred Items'); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('starred=true')); // Click handler test: Specific Feed items[3].click(); expect(items[3].classList.contains('active')).toBe(true); expect(document.getElementById('feed-title').textContent).toBe('Feed 1'); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('feed_id=1')); // Wait for async operations to complete to avoid unhandled rejections await new Promise(resolve => setTimeout(resolve, 0)); }); }); describe('renderItems', () => { it('should render "No items found" if empty', () => { renderItems([]); expect(document.getElementById('entries-list').innerHTML).toContain('No items found'); }); it('should render items with correct controls', () => { const items = [{ id: 1, title: 'Test', url: 'http://test.com', starred: true, read: false, feed: { title: 'Feed' }, created_at: new Date().toISOString() }]; renderItems(items); const starBtn = document.querySelector('.btn-star'); expect(starBtn.textContent).toBe('★'); expect(starBtn.classList.contains('active')).toBe(true); const readBtn = document.querySelector('.btn-read'); expect(readBtn.textContent).toBe('Mark Read'); expect(readBtn.classList.contains('unread')).toBe(true); }); }); describe('Interaction Toggles', () => { let btn; beforeEach(() => { btn = document.createElement('button'); document.body.appendChild(btn); }); afterEach(() => { if (btn) btn.remove(); }); it('should toggle star status', async () => { fetchMock.mockResolvedValue({ ok: true }); const newStatus = await toggleStar(1, false, btn); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({ method: 'PUT', body: JSON.stringify({ id: 1, starred: true }) })); expect(newStatus).toBe(true); expect(btn.textContent).toBe('★'); }); it('should toggle read status', async () => { fetchMock.mockResolvedValue({ ok: true }); // Setup DOM for title dimming const header = document.createElement('div'); header.className = 'entry-header'; const title = document.createElement('a'); title.className = 'entry-title'; header.appendChild(btn); // btn inside header header.appendChild(title); document.body.appendChild(header); const newStatus = await toggleRead(1, false, btn); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({ method: 'PUT', body: JSON.stringify({ id: 1, read: true }) })); expect(newStatus).toBe(true); expect(title.classList.contains('read')).toBe(true); }); }); describe('Error Handling', () => { it('fetchItems should handle existing list element error', async () => { fetchMock.mockRejectedValue(new Error('Fetch failed')); await expect(fetchItems()).rejects.toThrow('Fetch failed'); expect(document.getElementById('entries-list').innerHTML).toContain('Error loading items'); }); }); describe('init', () => { it('should initialize app if elements exist', async () => { // Mock fetch for the init calls fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); const addEventListenerSpy = vi.spyOn(document.getElementById('search-input'), 'addEventListener'); // init is already imported const { init } = await import('./app.js'); // Reset mocks fetchMock.mockClear(); init(); expect(fetchMock).toHaveBeenCalledTimes(2); // fetchFeeds + fetchItems expect(addEventListenerSpy).toHaveBeenCalledWith('keypress', expect.any(Function)); // Wait for async operations to complete await new Promise(resolve => setTimeout(resolve, 0)); }); it('should do nothing if feeds-nav missing', async () => { document.body.innerHTML = ''; // Clear DOM fetchMock.mockClear(); const { init } = await import('./app.js'); init(); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe('Search Interaction', () => { it('should trigger search on Enter', async () => { // Mock fetch for the init calls & search fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); // Re-setup DOM and Init const { init } = await import('./app.js'); init(); fetchMock.mockClear(); const searchInput = document.getElementById('search-input'); searchInput.value = 'test query'; // Create Enter keypress event const event = new KeyboardEvent('keypress', { key: 'Enter' }); searchInput.dispatchEvent(event); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=test+query')); expect(document.getElementById('feed-title').textContent).toBe('Search: test query'); // Wait for async operations to complete await new Promise(resolve => setTimeout(resolve, 0)); }); it('should ignore empty search', async () => { // Mock fetch for the init calls fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); const { init } = await import('./app.js'); init(); fetchMock.mockClear(); const searchInput = document.getElementById('search-input'); searchInput.value = ' '; const event = new KeyboardEvent('keypress', { key: 'Enter' }); searchInput.dispatchEvent(event); expect(fetchMock).not.toHaveBeenCalled(); // Wait for async operations to complete await new Promise(resolve => setTimeout(resolve, 0)); }); }); });