mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
166 lines
7.6 KiB
TypeScript
166 lines
7.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { dedupeFeeds } from "./dedupe.js";
|
|
import { discoverFeeds } from "./discovery.js";
|
|
import { parseFeedDocument } from "./feed-parsing.js";
|
|
import { renderDiscoveryOutput } from "./output/index.js";
|
|
import { WebCandidateFeedProbeProvider } from "./providers/web-feed-probe.js";
|
|
import { extractAlternateFeedLinks } from "./probe-site.js";
|
|
import { scoreFeed } from "./scoring.js";
|
|
import type { DiscoveredFeed, FeedDiscoveryProvider } from "./types.js";
|
|
import { assertSafeHttpUrl } from "./url-safety.js";
|
|
|
|
const baseFeed: DiscoveredFeed = {
|
|
title: "MicroSaaS Ideas",
|
|
description: "Ideas for indie SaaS founders",
|
|
homepageUrl: "https://example.com",
|
|
feedUrl: "https://example.com/feed.xml",
|
|
kind: "blog",
|
|
provider: "manual-curated",
|
|
score: 0,
|
|
confidence: 0.5,
|
|
tags: ["microsaas", "saas"]
|
|
};
|
|
|
|
describe("feed parsing", () => {
|
|
it("parses RSS feeds with sample items", () => {
|
|
const result = parseFeedDocument("https://example.com/rss.xml", `<?xml version="1.0"?><rss version="2.0"><channel><title>MicroSaaS Ideas</title><link>https://example.com</link><item><title>Launch tiny products</title><link>https://example.com/post</link><pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate></item></channel></rss>`);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.title).toBe("MicroSaaS Ideas");
|
|
expect(result.sampleItems[0]).toMatchObject({ title: "Launch tiny products", url: "https://example.com/post" });
|
|
});
|
|
|
|
it("detects podcast RSS feeds from item enclosures", () => {
|
|
const result = parseFeedDocument(
|
|
"https://example.com/podcast.xml",
|
|
`<?xml version="1.0"?><rss version="2.0"><channel><title>Audio Show</title><link>https://example.com</link><item><title>Episode 1</title><enclosure url="https://example.com/episode-1.mp3" type="audio/mpeg" length="12345" /></item></channel></rss>`
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.kind).toBe("podcast");
|
|
});
|
|
|
|
it("parses Atom and JSON Feed documents", () => {
|
|
const atom = parseFeedDocument("https://example.com/atom.xml", `<feed><title>Atom Feed</title><entry><title>Entry</title><updated>2026-06-09T00:00:00Z</updated></entry></feed>`);
|
|
const json = parseFeedDocument("https://example.com/feed.json", JSON.stringify({ version: "https://jsonfeed.org/version/1.1", title: "JSON Feed", items: [{ title: "Item", url: "https://example.com/item" }] }), "application/feed+json");
|
|
|
|
expect(atom.ok).toBe(true);
|
|
expect(json.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("site probing helpers", () => {
|
|
it("extracts alternate feed links", () => {
|
|
const links = extractAlternateFeedLinks(
|
|
`<html><head><link rel="alternate" type="application/rss+xml" href="/rss.xml"><link rel="alternate" type="text/html" href="/plain"></head></html>`,
|
|
"https://example.com/blog"
|
|
);
|
|
|
|
expect(links).toEqual(["https://example.com/rss.xml"]);
|
|
});
|
|
|
|
it("blocks private SSRF targets", async () => {
|
|
await expect(assertSafeHttpUrl("http://0.0.0.0/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://127.0.0.1/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://192.0.2.1/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://198.18.0.1/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://198.51.100.1/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://203.0.113.1/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://224.0.0.1/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://240.0.0.1/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://[::]/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://[2001:db8::1]/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://[ff02::1]/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://[::ffff:192.168.1.10]/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("http://[::192.168.1.10]/feed")).rejects.toThrow(/Blocked internal/);
|
|
await expect(assertSafeHttpUrl("file:///etc/passwd")).rejects.toThrow(/Unsupported URL protocol/);
|
|
});
|
|
|
|
it("ignores malformed configured candidate URLs", async () => {
|
|
const provider = new WebCandidateFeedProbeProvider({
|
|
cacheTtlSeconds: 60,
|
|
maxProviders: 1,
|
|
maxProbes: 1,
|
|
requestTimeoutMs: 10,
|
|
maxBodyBytes: 1024,
|
|
userAgent: "test",
|
|
opmlPaths: [],
|
|
candidateUrls: ["not-a-url|micro"],
|
|
podcastIndexApiKey: undefined,
|
|
podcastIndexApiSecret: undefined
|
|
});
|
|
|
|
await expect(provider.search({ q: "micro" })).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("scoring, dedupe, and output", () => {
|
|
it("scores relevant fresh feeds and dedupes canonical URLs", () => {
|
|
const scored = scoreFeed({ ...baseFeed, lastPublishedAt: new Date().toISOString() }, { q: "microsaas" });
|
|
const duplicate = { ...scored, feedUrl: "https://example.com/feed.xml?utm_source=test", score: scored.score - 0.1 };
|
|
|
|
expect(scored.score).toBeGreaterThan(0.5);
|
|
expect(dedupeFeeds([duplicate, scored])).toHaveLength(1);
|
|
});
|
|
|
|
it("renders JSON, OPML, and RSS outputs", () => {
|
|
const response = { query: "microsaas", normalizedQuery: "microsaas", count: 1, providerErrors: [], results: [scoreFeed(baseFeed, { q: "microsaas" })] };
|
|
|
|
expect(JSON.parse(renderDiscoveryOutput(response, "json"))).toMatchObject({ count: 1 });
|
|
expect(renderDiscoveryOutput(response, "opml")).toContain("<opml");
|
|
expect(renderDiscoveryOutput(response, "rss")).toContain("<rss");
|
|
});
|
|
});
|
|
|
|
describe("discovery orchestration", () => {
|
|
it("isolates provider failures", async () => {
|
|
const okProvider: FeedDiscoveryProvider = {
|
|
id: "ok-provider",
|
|
name: "OK",
|
|
enabledByDefault: true,
|
|
requiresApiKey: false,
|
|
async search() {
|
|
return [baseFeed];
|
|
}
|
|
};
|
|
const failingProvider: FeedDiscoveryProvider = {
|
|
id: "broken-provider",
|
|
name: "Broken",
|
|
enabledByDefault: true,
|
|
requiresApiKey: false,
|
|
async search() {
|
|
throw new Error("provider unavailable");
|
|
}
|
|
};
|
|
|
|
const response = await discoverFeeds({ q: "microsaas", includeUnvalidated: true }, { providers: [failingProvider, okProvider] });
|
|
|
|
expect(response.providerErrors).toEqual([{ provider: "broken-provider", error: "provider unavailable" }]);
|
|
expect(response.results).toHaveLength(1);
|
|
});
|
|
|
|
it("enforces the requested freshness window", async () => {
|
|
const provider: FeedDiscoveryProvider = {
|
|
id: "freshness-provider",
|
|
name: "Freshness",
|
|
enabledByDefault: true,
|
|
requiresApiKey: false,
|
|
async search() {
|
|
return [
|
|
{ ...baseFeed, feedUrl: "https://example.com/fresh.xml", lastPublishedAt: new Date(Date.now() - 5 * 86_400_000).toISOString() },
|
|
{ ...baseFeed, feedUrl: "https://example.com/stale.xml", lastPublishedAt: new Date(Date.now() - 60 * 86_400_000).toISOString() },
|
|
{ ...baseFeed, feedUrl: "https://example.com/unknown.xml", lastPublishedAt: undefined },
|
|
{ ...baseFeed, feedUrl: "https://example.com/invalid.xml", lastPublishedAt: "not-a-date" },
|
|
{ ...baseFeed, feedUrl: "https://example.com/future.xml", lastPublishedAt: new Date(Date.now() + 86_400_000).toISOString() }
|
|
];
|
|
}
|
|
};
|
|
|
|
const response = await discoverFeeds(
|
|
{ q: "microsaas", freshnessDays: 30, includeUnvalidated: true },
|
|
{ providers: [provider] }
|
|
);
|
|
|
|
expect(response.results.map((feed) => feed.feedUrl)).toEqual(["https://example.com/fresh.xml"]);
|
|
});
|
|
});
|