Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d15b572
feat: Add FL wedge support with independent dual-mode control
masonfox Jan 23, 2026
7f17c08
refactor: Extract wedge toggle into shared component with lightning b…
masonfox Jan 23, 2026
81e3a44
refactor: Create unified mobile bottom sheet with separate panels
masonfox Jan 23, 2026
7d0390d
feat: Add vertical divider to mobile stats layout
masonfox Jan 23, 2026
86995b0
feat: Dismiss mobile keyboard automatically on search submit
masonfox Jan 23, 2026
3ec9741
feat: Auto-scroll to books section on mobile dual-mode search
masonfox Jan 23, 2026
7f9a308
fix: Increase bottom padding in mobile dual mode to prevent bottom sh…
masonfox Jan 23, 2026
10563a6
feat: Add external link icon to torrent titles
masonfox Jan 23, 2026
a27636d
refactor: Make entire title line clickable and improve spacing
masonfox Jan 23, 2026
b611766
feat: Add freeleech and VIP badges before torrent titles
masonfox Jan 23, 2026
128047a
feat: Hide FL wedge toggles for freeleech torrents
masonfox Jan 23, 2026
14d4a0a
fix: Make only external link icon clickable, not entire title
masonfox Jan 23, 2026
389d929
feat: Add test coverage support with vitest
masonfox Jan 23, 2026
2d8c9bb
fix: Add type module to package.json to eliminate warning
masonfox Jan 23, 2026
03634aa
fix: Run test coverage in PR checks workflow
masonfox Jan 23, 2026
d96de43
fix: Run test coverage in publish workflow
masonfox Jan 23, 2026
389e35a
feat: Improve mobile layout for torrent actions
masonfox Jan 23, 2026
58fedbc
feat: Increase gap between FL wedge and download button on desktop
masonfox Jan 23, 2026
7741e11
refactor: Pass torrentId directly to use-wedge endpoint
masonfox Jan 23, 2026
20c227d
feat: Bust user stats cache after successful downloads
masonfox Jan 23, 2026
177f9b7
test: Add comprehensive tests for use-wedge endpoint
masonfox Jan 23, 2026
8ebb4d5
feat: Account for FL wedge in ratio impact calculations
masonfox Jan 23, 2026
b2924e0
refactor: Consolidate dual search desktop layout into integrated 2-ro…
masonfox Jan 23, 2026
86e95c1
fix: Change ratio text from 'Same' to 'No Change' for freeleech torrents
masonfox Jan 23, 2026
0069c7b
test: achieve 95%+ test coverage across the codebase
masonfox Jan 23, 2026
af04070
chore: bump version to 2.4.0
masonfox Jan 23, 2026
9b9915b
feat: Add Freeleach Wedges feature to README and update screenshots
masonfox Jan 23, 2026
c0f2d6b
fix: Disable freeleech wedge button for VIP torrents
masonfox Jan 23, 2026
c71ff10
Merge branch 'main' into feature/fl-wedge-support
masonfox Jan 23, 2026
99b9d12
chore: bump version to 2.5.0
masonfox Jan 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run test suite
run: npm test
- name: Run test suite with coverage
run: npm run test:coverage

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
continue-on-error: true
files: ./coverage/lcov.info
fail_ci_if_error: false

docker-build:
name: Docker Build Test
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run test suite
run: npm test
- name: Run test suite with coverage
run: npm run test:coverage

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
fail_ci_if_error: false

- name: Log into GitHub Container Registry
uses: docker/login-action@v3
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Dual fetch allows you to fetch _both_ ebook and audibooks at the same time in a
### Key Features

- πŸ”Ž **Search MAM**: Clean, responsive interface to search MyAnonamouse's extensive library
- ⚑ **Freeleach Wedges**: Redeem freeleach wedges before downloading and check your wedge count
- 🌟 **Dual-Fetch Mode**: Search and download both ebook and audiobook simultaneously with streamlined selection workflow
- πŸ‘‰οΈ **One-click Downloads**: Instantly send torrents to your qBittorrent instance with a single click
- 🀝 **Direct Integration**: Automatically authenticates with both MAM and qBittorrent APIs
Expand Down
202 changes: 201 additions & 1 deletion __tests__/add.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from '../app/api/add/route.js';
import * as qbittorrent from '../src/lib/qbittorrent';
import * as userStatsRoute from '../app/api/user-stats/route.js';

vi.mock('../src/lib/config', () => ({
config: { qbUrl: 'http://qb', qbUser: 'user', qbPass: 'pass', qbCategory: 'cat' }
Expand All @@ -9,8 +10,15 @@ vi.mock('../src/lib/qbittorrent', () => ({
qbLogin: vi.fn(async () => 'cookie'),
qbAddUrl: vi.fn(async () => true)
}));
vi.mock('../app/api/user-stats/route.js', () => ({
bustStatsCache: vi.fn()
}));

describe('add route', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns 400 if no downloadUrl provided', async () => {
const req = { json: async () => ({}) };
const res = await POST(req);
Expand All @@ -26,6 +34,15 @@ describe('add route', () => {
expect(json.ok).toBe(true);
});

it('busts stats cache after successful download', async () => {
const req = { json: async () => ({ title: 'test', downloadUrl: 'magnet:?xt=...', category: 'cat' }) };
const res = await POST(req);
const json = await res.json();

expect(json.ok).toBe(true);
expect(userStatsRoute.bustStatsCache).toHaveBeenCalledTimes(1);
});

it('returns 500 if qbittorrent throws', async () => {
qbittorrent.qbAddUrl.mockImplementationOnce(() => { throw new Error('fail'); });

Expand All @@ -36,4 +53,187 @@ describe('add route', () => {
expect(json.ok).toBe(false);
expect(json.error).toMatch(/fail/);
});

it('returns 500 with fallback message if error has no message', async () => {
qbittorrent.qbAddUrl.mockImplementationOnce(() => { throw 'string error'; });

const req = { json: async () => ({ title: 'test', downloadUrl: 'magnet:?xt=...', category: 'cat' }) };
const res = await POST(req);
const json = await res.json();

expect(json.ok).toBe(false);
expect(json.error).toBe('Add failed');
});

it('does not bust cache if download fails', async () => {
qbittorrent.qbAddUrl.mockImplementationOnce(() => { throw new Error('fail'); });

const req = { json: async () => ({ title: 'test', downloadUrl: 'magnet:?xt=...', category: 'cat' }) };
await POST(req);

expect(userStatsRoute.bustStatsCache).not.toHaveBeenCalled();
});

describe('wedge integration', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});

it('purchases FL wedge before adding torrent when useWedge is true', async () => {
// Mock successful wedge purchase
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true })
});

const req = {
json: async () => ({
title: 'Test Book',
downloadUrl: 'magnet:?xt=...',
torrentId: '12345',
useWedge: true
}),
nextUrl: { origin: 'http://localhost:3000' }
};

const res = await POST(req);
const json = await res.json();

expect(json.ok).toBe(true);
expect(json.wedgeUsed).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/use-wedge',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrentId: '12345' })
})
);
// Verify qBittorrent was called after wedge purchase
expect(qbittorrent.qbAddUrl).toHaveBeenCalled();
});

it('returns error when wedge purchase fails', async () => {
// Mock failed wedge purchase
global.fetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({ success: false, error: 'Not enough wedges' })
});

const req = {
json: async () => ({
title: 'Test Book',
downloadUrl: 'magnet:?xt=...',
torrentId: '12345',
useWedge: true
}),
nextUrl: { origin: 'http://localhost:3000' }
};

const res = await POST(req);
const json = await res.json();

expect(json.ok).toBe(false);
expect(json.wedgeFailed).toBe(true);
expect(json.error).toContain('Not enough wedges');
// Should NOT attempt to add torrent to qBittorrent
expect(qbittorrent.qbAddUrl).not.toHaveBeenCalled();
});

it('does not bust cache when wedge purchase fails', async () => {
global.fetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({ success: false, error: 'Error' })
});

const req = {
json: async () => ({
title: 'Test Book',
downloadUrl: 'magnet:?xt=...',
torrentId: '12345',
useWedge: true
}),
nextUrl: { origin: 'http://localhost:3000' }
};

await POST(req);
expect(userStatsRoute.bustStatsCache).not.toHaveBeenCalled();
});

it('returns error when wedge response has ok=true but success=false', async () => {
// Mock wedge purchase with success: false
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: false, error: 'Insufficient bonus points' })
});

const req = {
json: async () => ({
title: 'Test Book',
downloadUrl: 'magnet:?xt=...',
torrentId: '12345',
useWedge: true
}),
nextUrl: { origin: 'http://localhost:3000' }
};

const res = await POST(req);
const json = await res.json();

expect(json.ok).toBe(false);
expect(json.wedgeFailed).toBe(true);
expect(json.error).toBe('Insufficient bonus points');
expect(qbittorrent.qbAddUrl).not.toHaveBeenCalled();
});

it('busts cache after successful wedge purchase and download', async () => {
// Mock successful wedge purchase
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true })
});

const req = {
json: async () => ({
title: 'Test Book',
downloadUrl: 'magnet:?xt=...',
torrentId: '12345',
useWedge: true
}),
nextUrl: { origin: 'http://localhost:3000' }
};

await POST(req);

expect(userStatsRoute.bustStatsCache).toHaveBeenCalledTimes(1);
});

it('handles wedge purchase when response has no error message', async () => {
global.fetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({ success: false })
});

const req = {
json: async () => ({
title: 'Test Book',
downloadUrl: 'magnet:?xt=...',
torrentId: '12345',
useWedge: true
}),
nextUrl: { origin: 'http://localhost:3000' }
};

const res = await POST(req);
const json = await res.json();

expect(json.ok).toBe(false);
expect(json.wedgeFailed).toBe(true);
expect(json.error).toBe('Failed to purchase FL wedge');
});
});
});
1 change: 1 addition & 0 deletions __tests__/helpers/test-factories.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function createMamTorrent(overrides = {}) {
filetype: 'epub',
added: '2025-09-25',
vip: 0,
free: 0,
my_snatched: 0,
author_info: '{"author":"Test Author"}',
seeders: 15,
Expand Down
83 changes: 83 additions & 0 deletions __tests__/message-banner.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import MessageBanner from '../app/MessageBanner.jsx';

describe('MessageBanner', () => {
it('renders error message with red styling', () => {
render(<MessageBanner type="error" text="Error occurred" />);

const banner = screen.getByText('Error occurred').closest('div');
expect(screen.getByText('Error occurred')).toBeInTheDocument();
expect(screen.getByText('❌')).toBeInTheDocument();
expect(banner).toHaveClass('bg-red-100');
expect(banner).toHaveClass('border-red-300');
expect(banner).toHaveClass('text-red-900');
});

it('renders success message with green styling', () => {
render(<MessageBanner type="success" text="Success!" />);

const banner = screen.getByText('Success!').closest('div');
expect(screen.getByText('Success!')).toBeInTheDocument();
expect(screen.getByText('βœ…')).toBeInTheDocument();
expect(banner).toHaveClass('bg-green-100');
expect(banner).toHaveClass('border-green-300');
expect(banner).toHaveClass('text-green-900');
});

it('renders info message by default', () => {
render(<MessageBanner text="Information" />);

const banner = screen.getByText('Information').closest('div');
expect(screen.getByText('Information')).toBeInTheDocument();
expect(screen.getByText('ℹ️')).toBeInTheDocument();
expect(banner).toHaveClass('bg-blue-100');
expect(banner).toHaveClass('border-blue-300');
expect(banner).toHaveClass('text-blue-900');
});

it('renders info message when type is explicitly set to info', () => {
render(<MessageBanner type="info" text="Information" />);

const banner = screen.getByText('Information').closest('div');
expect(screen.getByText('Information')).toBeInTheDocument();
expect(screen.getByText('ℹ️')).toBeInTheDocument();
expect(banner).toHaveClass('bg-blue-100');
});

it('renders info message for unknown type', () => {
render(<MessageBanner type="unknown" text="Unknown type" />);

const banner = screen.getByText('Unknown type').closest('div');
expect(screen.getByText('Unknown type')).toBeInTheDocument();
expect(screen.getByText('ℹ️')).toBeInTheDocument();
expect(banner).toHaveClass('bg-blue-100');
});

it('applies all required CSS classes', () => {
render(<MessageBanner type="error" text="Test" />);

const banner = screen.getByText('Test').closest('div');
expect(banner).toHaveClass('my-5');
expect(banner).toHaveClass('p-4');
expect(banner).toHaveClass('rounded-md');
expect(banner).toHaveClass('flex');
expect(banner).toHaveClass('items-center');
expect(banner).toHaveClass('gap-2');
});

it('applies correct font size to icon', () => {
render(<MessageBanner type="error" text="Test" />);

const icon = screen.getByText('❌');
expect(icon).toHaveStyle({ fontSize: '20px' });
});

it('renders text in a strong tag', () => {
render(<MessageBanner type="info" text="Important message" />);

const strongElement = screen.getByText('Important message');
expect(strongElement.tagName).toBe('STRONG');
});
});
Loading