135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { closeDatabase, getDatabase } from './database.js';
|
|
import {
|
|
discardFeedEntries,
|
|
fetchDueFeeds,
|
|
fetchFeed,
|
|
importFeedEntries,
|
|
listFeedEntries,
|
|
saveFeedSource
|
|
} from './feeds.js';
|
|
import { getPiece } from './content.js';
|
|
|
|
vi.mock('node:dns/promises', () => ({
|
|
lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }])
|
|
}));
|
|
|
|
const validFeed = `<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title><item>
|
|
<guid>entry-1</guid><title>Ein Beitrag</title><link>https://example.org/post</link>
|
|
<description><![CDATA[<p>Kurz <strong>und</strong> gut.</p><script>evil()</script>]]></description>
|
|
<pubDate>Sat, 24 Oct 2026 12:00:00 GMT</pubDate></item></channel></rss>`;
|
|
|
|
let directory: string;
|
|
|
|
beforeEach(() => {
|
|
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-feed-test-'));
|
|
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
|
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
|
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
|
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
closeDatabase();
|
|
delete process.env.SENDEWERK_DB_PATH;
|
|
delete process.env.SENDEWERK_UPLOAD_DIR;
|
|
rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('RSS import inbox', () => {
|
|
it('keeps one entry across fetches and preserves a discard decision', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () => new Response(validFeed, { headers: { 'content-type': 'application/rss+xml' } })
|
|
)
|
|
);
|
|
const feedId = saveFeedSource(null, {
|
|
name: 'Feed',
|
|
url: 'https://example.org/feed.xml',
|
|
isEnabled: true,
|
|
refreshIntervalMinutes: 60,
|
|
defaultCategoryId: null
|
|
});
|
|
await fetchFeed(feedId);
|
|
const [entry] = listFeedEntries();
|
|
expect(entry.title).toBe('Ein Beitrag');
|
|
expect(discardFeedEntries([entry.id])).toBe(1);
|
|
await fetchFeed(feedId);
|
|
expect(listFeedEntries()).toHaveLength(0);
|
|
expect(listFeedEntries('discarded')).toHaveLength(1);
|
|
});
|
|
|
|
it('sanitizes foreign HTML and retains the original source on explicit import', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => new Response(validFeed))
|
|
);
|
|
const feedId = saveFeedSource(null, {
|
|
name: 'Feed',
|
|
url: 'https://example.org/feed.xml',
|
|
isEnabled: true,
|
|
refreshIntervalMinutes: 60,
|
|
defaultCategoryId: 'cat-jc'
|
|
});
|
|
await fetchFeed(feedId);
|
|
const [entry] = listFeedEntries();
|
|
const [pieceId] = importFeedEntries([entry.id], []);
|
|
const piece = getPiece(pieceId)!;
|
|
expect(piece.summary).toContain('Kurz und gut.');
|
|
expect(piece.summary).not.toContain('<script>');
|
|
expect(piece.sources).toEqual([
|
|
{ label: 'RSS: Originalbeitrag', url: 'https://example.org/post', timecodeSeconds: null }
|
|
]);
|
|
expect(piece.categoryIds).toEqual(['cat-jc']);
|
|
});
|
|
|
|
it('isolates a broken feed while fetching other due feeds', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async (url: URL) => new Response(url.href.includes('broken') ? '<not-feed />' : validFeed)
|
|
)
|
|
);
|
|
saveFeedSource(null, {
|
|
name: 'A broken',
|
|
url: 'https://example.org/broken.xml',
|
|
isEnabled: true,
|
|
refreshIntervalMinutes: 60,
|
|
defaultCategoryId: null
|
|
});
|
|
saveFeedSource(null, {
|
|
name: 'B valid',
|
|
url: 'https://example.org/valid.xml',
|
|
isEnabled: true,
|
|
refreshIntervalMinutes: 60,
|
|
defaultCategoryId: null
|
|
});
|
|
await fetchDueFeeds();
|
|
expect(listFeedEntries()).toHaveLength(1);
|
|
const errors = getDatabase()
|
|
.prepare("SELECT count(*) AS count FROM feed_sources WHERE last_error <> ''")
|
|
.get() as { count: number };
|
|
expect(errors.count).toBe(1);
|
|
});
|
|
|
|
it('blocks loopback targets before sending a request', async () => {
|
|
const request = vi.fn();
|
|
vi.stubGlobal('fetch', request);
|
|
const feedId = saveFeedSource(null, {
|
|
name: 'Local',
|
|
url: 'http://127.0.0.1/feed.xml',
|
|
isEnabled: false,
|
|
refreshIntervalMinutes: 60,
|
|
defaultCategoryId: null
|
|
});
|
|
await expect(fetchFeed(feedId)).rejects.toThrow('FEED_BLOCKED');
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
});
|