Skip to content

Commit 19bc430

Browse files
ascorbicclaude
andauthored
test(feed): add comprehensive integration tests (#84)
- Add MSW for HTTP mocking in tests - Create test fixtures for RSS 2.0, Atom, RDF, and edge case feeds - Implement HTTP conditional request tests (ETags, Last-Modified, 304 responses) - Add error handling tests for network failures and malformed feeds - Test feed parsing edge cases (missing GUIDs, encodings, content types) - Include real feed integration tests against stable external feeds - Verify Astro loader interface compliance and data store integration - Replace placeholder hello.test.ts with comprehensive test suite All 39 tests pass, providing robust coverage of feed parsing, HTTP caching, and error handling scenarios. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent bb8aed2 commit 19bc430

17 files changed

+2166
-11
lines changed

packages/feed/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@arethetypeswrong/cli": "^0.17.3",
2222
"@types/feedparser": "^2.2.8",
2323
"astro": "5.2.1",
24+
"msw": "^2.10.2",
2425
"publint": "^0.3.2",
2526
"tsup": "^8.3.6",
2627
"typescript": "^5.7.3"
@@ -41,7 +42,7 @@
4142
},
4243
"homepage": "https://github.com/ascorbic/astro-loaders",
4344
"dependencies": {
44-
"feedparser": "^2.2.10",
45-
"@ascorbic/loader-utils": "workspace:^"
45+
"@ascorbic/loader-utils": "workspace:^",
46+
"feedparser": "^2.2.10"
4647
}
4748
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import { readFileSync } from "fs";
3+
import { fileURLToPath } from "url";
4+
import { dirname, join } from "path";
5+
import { feedLoader } from "../src/feed-loader.js";
6+
import { ItemSchema } from "../src/schema.js";
7+
import { server, http, HttpResponse } from "./setup.js";
8+
9+
const __filename = fileURLToPath(import.meta.url);
10+
const __dirname = dirname(__filename);
11+
12+
const mockStore = {
13+
data: new Map(),
14+
clear() {
15+
this.data.clear();
16+
},
17+
set({ id, data, rendered }: { id: string; data: any; rendered: any }) {
18+
this.data.set(id, { data, rendered });
19+
},
20+
get(id: string) {
21+
return this.data.get(id);
22+
},
23+
has(id: string) {
24+
return this.data.has(id);
25+
},
26+
keys() {
27+
return this.data.keys();
28+
},
29+
values() {
30+
return Array.from(this.data.values());
31+
}
32+
};
33+
34+
const mockMeta = {
35+
data: new Map(),
36+
get(key: string) {
37+
return this.data.get(key);
38+
},
39+
set(key: string, value: any) {
40+
this.data.set(key, value);
41+
},
42+
has(key: string) {
43+
return this.data.has(key);
44+
},
45+
delete(key: string) {
46+
return this.data.delete(key);
47+
}
48+
};
49+
50+
const mockLogger = {
51+
info: () => {},
52+
warn: () => {},
53+
error: () => {}
54+
};
55+
56+
const mockParseData = async ({ data }: { id: string; data: any }) => {
57+
const result = ItemSchema.parse(data);
58+
return result;
59+
};
60+
61+
describe("Astro Loader Interface Compliance", () => {
62+
beforeEach(() => {
63+
mockStore.clear();
64+
mockMeta.data.clear();
65+
});
66+
67+
describe("Loader Interface", () => {
68+
it("should implement the Loader interface correctly", () => {
69+
const loader = feedLoader({ url: "https://example.com/feed.xml" });
70+
71+
expect(loader).toHaveProperty("name");
72+
expect(loader).toHaveProperty("load");
73+
expect(loader).toHaveProperty("schema");
74+
75+
expect(typeof loader.name).toBe("string");
76+
expect(typeof loader.load).toBe("function");
77+
expect(loader.schema).toBeDefined();
78+
79+
expect(loader.name).toBe("feed-loader");
80+
});
81+
82+
it("should have correct schema export", () => {
83+
const loader = feedLoader({ url: "https://example.com/feed.xml" });
84+
85+
expect(loader.schema).toBe(ItemSchema);
86+
});
87+
88+
it("should accept URL as string or URL object", () => {
89+
const stringLoader = feedLoader({ url: "https://example.com/feed.xml" });
90+
const urlLoader = feedLoader({ url: new URL("https://example.com/feed.xml") });
91+
92+
expect(stringLoader.name).toBe("feed-loader");
93+
expect(urlLoader.name).toBe("feed-loader");
94+
});
95+
});
96+
97+
describe("Data Store Integration", () => {
98+
it("should clear store before loading new data", async () => {
99+
const rssContent = readFileSync(join(__dirname, "fixtures/rss2.xml"), "utf-8");
100+
101+
server.use(
102+
http.get("https://example.com/feed.xml", () => {
103+
return new HttpResponse(rssContent, {
104+
status: 200,
105+
headers: {
106+
"content-type": "application/rss+xml"
107+
}
108+
});
109+
})
110+
);
111+
112+
mockStore.set({
113+
id: "old-item",
114+
data: { title: "Old Item" },
115+
rendered: { html: "Old content" }
116+
});
117+
118+
expect(mockStore.data.size).toBe(1);
119+
120+
const loader = feedLoader({ url: "https://example.com/feed.xml" });
121+
await loader.load({
122+
store: mockStore as any,
123+
logger: mockLogger as any,
124+
parseData: mockParseData as any,
125+
meta: mockMeta
126+
});
127+
128+
expect(mockStore.data.size).toBe(3);
129+
expect(mockStore.has("old-item")).toBe(false);
130+
});
131+
132+
it("should store items with correct structure", async () => {
133+
const rssContent = readFileSync(join(__dirname, "fixtures/rss2.xml"), "utf-8");
134+
135+
server.use(
136+
http.get("https://example.com/feed.xml", () => {
137+
return new HttpResponse(rssContent, {
138+
status: 200,
139+
headers: {
140+
"content-type": "application/rss+xml"
141+
}
142+
});
143+
})
144+
);
145+
146+
const loader = feedLoader({ url: "https://example.com/feed.xml" });
147+
await loader.load({
148+
store: mockStore as any,
149+
logger: mockLogger as any,
150+
parseData: mockParseData as any,
151+
meta: mockMeta
152+
});
153+
154+
const storedItem = mockStore.get("https://example.com/first-post");
155+
156+
expect(storedItem).toHaveProperty("data");
157+
expect(storedItem).toHaveProperty("rendered");
158+
expect(storedItem.rendered).toHaveProperty("html");
159+
160+
expect(storedItem.data.title).toBe("First Post");
161+
expect(storedItem.data.link).toBe("https://example.com/first-post");
162+
expect(storedItem.data.guid).toBe("https://example.com/first-post");
163+
expect(storedItem.rendered.html).toBe("This is the first post in our RSS feed");
164+
});
165+
166+
it("should handle empty description gracefully", async () => {
167+
const feedContent = `<?xml version="1.0" encoding="UTF-8"?>
168+
<rss version="2.0">
169+
<channel>
170+
<title>Test Feed</title>
171+
<item>
172+
<title>Item without description</title>
173+
<link>https://example.com/no-desc</link>
174+
<guid>https://example.com/no-desc</guid>
175+
</item>
176+
</channel>
177+
</rss>`;
178+
179+
server.use(
180+
http.get("https://example.com/no-desc.xml", () => {
181+
return new HttpResponse(feedContent, {
182+
status: 200,
183+
headers: {
184+
"content-type": "application/rss+xml"
185+
}
186+
});
187+
})
188+
);
189+
190+
const loader = feedLoader({ url: "https://example.com/no-desc.xml" });
191+
await loader.load({
192+
store: mockStore as any,
193+
logger: mockLogger as any,
194+
parseData: mockParseData as any,
195+
meta: mockMeta
196+
});
197+
198+
const storedItem = mockStore.get("https://example.com/no-desc");
199+
expect(storedItem).toBeDefined();
200+
expect(storedItem.rendered.html).toBe("");
201+
});
202+
});
203+
204+
describe("Schema Validation", () => {
205+
it("should validate parsed data against schema", async () => {
206+
const rssContent = readFileSync(join(__dirname, "fixtures/rss2.xml"), "utf-8");
207+
208+
server.use(
209+
http.get("https://example.com/schema-test.xml", () => {
210+
return new HttpResponse(rssContent, {
211+
status: 200,
212+
headers: {
213+
"content-type": "application/rss+xml"
214+
}
215+
});
216+
})
217+
);
218+
219+
const loader = feedLoader({ url: "https://example.com/schema-test.xml" });
220+
await loader.load({
221+
store: mockStore as any,
222+
logger: mockLogger as any,
223+
parseData: mockParseData as any,
224+
meta: mockMeta
225+
});
226+
227+
const storedItem = mockStore.get("https://example.com/first-post");
228+
const validationResult = ItemSchema.safeParse(storedItem!.data);
229+
230+
expect(validationResult.success).toBe(true);
231+
if (validationResult.success) {
232+
expect(validationResult.data.title).toBe("First Post");
233+
expect(validationResult.data.link).toBe("https://example.com/first-post");
234+
expect(validationResult.data.guid).toBe("https://example.com/first-post");
235+
}
236+
});
237+
238+
it("should handle all schema fields correctly", async () => {
239+
const complexRss = `<?xml version="1.0" encoding="UTF-8"?>
240+
<rss version="2.0">
241+
<channel>
242+
<title>Complex Feed</title>
243+
<item>
244+
<title>Complex Item</title>
245+
<link>https://example.com/complex</link>
246+
<description>Complex description</description>
247+
<pubDate>Wed, 21 Jun 2023 10:00:00 GMT</pubDate>
248+
<guid>https://example.com/complex</guid>
249+
<author>author@example.com (Author Name)</author>
250+
<category>Technology</category>
251+
<category>News</category>
252+
<enclosure url="https://example.com/file.mp3" length="1024" type="audio/mpeg" />
253+
</item>
254+
</channel>
255+
</rss>`;
256+
257+
server.use(
258+
http.get("https://example.com/complex.xml", () => {
259+
return new HttpResponse(complexRss, {
260+
status: 200,
261+
headers: {
262+
"content-type": "application/rss+xml"
263+
}
264+
});
265+
})
266+
);
267+
268+
const loader = feedLoader({ url: "https://example.com/complex.xml" });
269+
await loader.load({
270+
store: mockStore as any,
271+
logger: mockLogger as any,
272+
parseData: mockParseData as any,
273+
meta: mockMeta
274+
});
275+
276+
const storedItem = mockStore.get("https://example.com/complex");
277+
expect(storedItem).toBeDefined();
278+
279+
const validationResult = ItemSchema.safeParse(storedItem!.data);
280+
expect(validationResult.success).toBe(true);
281+
282+
if (validationResult.success) {
283+
expect(validationResult.data.title).toBe("Complex Item");
284+
expect(validationResult.data.categories).toContain("Technology");
285+
expect(validationResult.data.categories).toContain("News");
286+
expect(validationResult.data.enclosures).toHaveLength(1);
287+
expect(validationResult.data.enclosures![0]!.url).toBe("https://example.com/file.mp3");
288+
expect(validationResult.data.enclosures![0]!.type).toBe("audio/mpeg");
289+
}
290+
});
291+
});
292+
293+
});

0 commit comments

Comments
 (0)