Skip to content

Commit c5eb397

Browse files
authored
test(posts): add unit tests for data-fetching and markdown parsing (#346)
Signed-off-by: Harshit Kandpal <kandpalhar@gmail.com>
1 parent f708fb3 commit c5eb397

File tree

1 file changed

+329
-0
lines changed

1 file changed

+329
-0
lines changed

src/lib/__tests__/posts.test.ts

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,333 @@ More content.
258258
});
259259
});
260260
});
261+
262+
describe("slug generation", () => {
263+
it("uses frontmatter slug when provided", () => {
264+
fsMocks.existsSync.mockReturnValue(true);
265+
fsMocks.readdirSync.mockReturnValue(["post.md"]);
266+
fsMocks.readFileSync.mockReturnValue(`+++
267+
title = "My Post"
268+
slug = "explicit-slug"
269+
date = 2026-01-01
270+
+++
271+
Content`);
272+
273+
const posts = getAllPosts();
274+
expect(posts[0].slug).toBe("explicit-slug");
275+
});
276+
277+
it("derives slug from filename when no frontmatter slug is given", () => {
278+
fsMocks.existsSync.mockReturnValue(true);
279+
fsMocks.readdirSync.mockReturnValue(["my-filename.md"]);
280+
fsMocks.readFileSync.mockReturnValue(`+++
281+
title = "My Post"
282+
date = 2026-01-01
283+
+++
284+
Content`);
285+
286+
const posts = getAllPosts();
287+
expect(posts[0].slug).toBe("my-filename");
288+
});
289+
290+
it("sanitizes filename-derived slug: lowercase, spaces to hyphens, removes special chars", () => {
291+
fsMocks.existsSync.mockReturnValue(true);
292+
fsMocks.readdirSync.mockReturnValue(["My Cool Post! (2026).md"]);
293+
fsMocks.readFileSync.mockReturnValue(`+++
294+
title = "My Cool Post! (2026)"
295+
date = 2026-01-01
296+
+++
297+
Content`);
298+
299+
const posts = getAllPosts();
300+
// Expect: lowercase, spaces → hyphens, remove punctuation except hyphens
301+
expect(posts[0].slug).toBe("my-cool-post-2026");
302+
});
303+
304+
it("handles uppercase and mixed-case filenames", () => {
305+
fsMocks.existsSync.mockReturnValue(true);
306+
fsMocks.readdirSync.mockReturnValue(["UPPERCASE_POST.md"]);
307+
fsMocks.readFileSync.mockReturnValue(`+++
308+
title = "Uppercase"
309+
date = 2026-01-01
310+
+++
311+
Content`);
312+
313+
const posts = getAllPosts();
314+
expect(posts[0].slug).toBe("uppercase-post"); // or "uppercase-post" depending on impl
315+
});
316+
317+
it("uses the slug for lookups in getPostBySlug", () => {
318+
fsMocks.existsSync.mockReturnValue(true);
319+
fsMocks.readdirSync.mockReturnValue(["filename.md"]);
320+
fsMocks.readFileSync.mockReturnValue(`+++
321+
title = "Test"
322+
slug = "custom-lookup"
323+
date = 2026-01-01
324+
+++
325+
Content`);
326+
327+
const post = getPostBySlug("custom-lookup");
328+
expect(post).not.toBeNull();
329+
expect(post?.slug).toBe("custom-lookup");
330+
});
331+
332+
it("returns null when slug does not match any post (derived or explicit)", () => {
333+
fsMocks.existsSync.mockReturnValue(true);
334+
fsMocks.readdirSync.mockReturnValue(["existing.md"]);
335+
fsMocks.readFileSync.mockReturnValue(`+++
336+
title = "Existing"
337+
date = 2026-01-01
338+
+++
339+
Content`);
340+
341+
const post = getPostBySlug("nonexistent");
342+
expect(post).toBeNull();
343+
});
344+
});
345+
346+
describe("date parsing and sorting", () => {
347+
it("parses YYYY-MM-DD date strings correctly", () => {
348+
fsMocks.existsSync.mockReturnValue(true);
349+
fsMocks.readdirSync.mockReturnValue(["post.md"]);
350+
fsMocks.readFileSync.mockReturnValue(`+++
351+
title = "Post"
352+
date = 2025-12-25
353+
+++
354+
Content`);
355+
356+
const posts = getAllPosts();
357+
expect(posts[0].date).toBe("2025-12-25T00:00:00.000Z");
358+
});
359+
360+
it("parses ISO 8601 date strings with time", () => {
361+
fsMocks.existsSync.mockReturnValue(true);
362+
fsMocks.readdirSync.mockReturnValue(["post.md"]);
363+
fsMocks.readFileSync.mockReturnValue(`+++
364+
title = "Post"
365+
date = "2026-03-20T15:30:00Z"
366+
+++
367+
Content`);
368+
369+
const posts = getAllPosts();
370+
expect(posts[0].date).toBe("2026-03-20T15:30:00.000Z");
371+
});
372+
373+
it("handles missing date by falling back to a default (e.g., file mtime or epoch)", () => {
374+
// Implementation dependent; test that it doesn't crash and returns some date string.
375+
fsMocks.existsSync.mockReturnValue(true);
376+
fsMocks.readdirSync.mockReturnValue(["nodate.md"]);
377+
fsMocks.readFileSync.mockReturnValue(`+++
378+
title = "No Date"
379+
+++
380+
Content`);
381+
382+
const posts = getAllPosts();
383+
expect(posts[0]).toHaveProperty("date");
384+
expect(typeof posts[0].date).toBe("string");
385+
});
386+
it("skips posts with invalid date formats (or treats as oldest)", () => {
387+
fsMocks.existsSync.mockReturnValue(true);
388+
fsMocks.readdirSync.mockReturnValue(["valid.md", "invalid.md"]);
389+
fsMocks.readFileSync.mockImplementation((filePath: string) => {
390+
if (filePath.includes("valid.md")) {
391+
return `+++\ntitle = "Valid"\ndate = 2026-01-01\n+++\nContent`;
392+
} else {
393+
return `+++\ntitle = "Invalid"\ndate = "not-a-date"\n+++\nContent`;
394+
}
395+
});
396+
397+
const posts = getAllPosts();
398+
399+
// Both posts are kept (length 2)
400+
expect(posts).toHaveLength(2);
401+
402+
// At least one post has the correct valid date
403+
expect(posts.some(p => p.date === "2026-01-01T00:00:00.000Z")).toBe(true);
404+
405+
// All posts have some date string (fallback for invalid)
406+
expect(posts.every(p => typeof p.date === "string")).toBe(true);
407+
});
408+
409+
it("sorts posts in descending date order (newest first)", () => {
410+
fsMocks.existsSync.mockReturnValue(true);
411+
fsMocks.readdirSync.mockReturnValue(["old.md", "new.md", "middle.md"]);
412+
fsMocks.readFileSync.mockImplementation((filePath: string) => {
413+
if (filePath.includes("old.md"))
414+
return `+++\ntitle = "Old"\ndate = 2024-01-01\n+++`;
415+
if (filePath.includes("middle.md"))
416+
return `+++\ntitle = "Middle"\ndate = 2025-01-01\n+++`;
417+
return `+++\ntitle = "New"\ndate = 2026-01-01\n+++`;
418+
});
419+
420+
const posts = getAllPosts();
421+
expect(posts.map(p => p.title)).toEqual(["New", "Middle", "Old"]);
422+
});
423+
424+
it("handles equal dates by stable sort (e.g., by title or filename)", () => {
425+
fsMocks.existsSync.mockReturnValue(true);
426+
fsMocks.readdirSync.mockReturnValue(["a.md", "b.md"]);
427+
fsMocks.readFileSync.mockImplementation((filePath: string) => {
428+
if (filePath.includes("a.md"))
429+
return `+++\ntitle = "A"\ndate = 2026-01-01\n+++`;
430+
return `+++\ntitle = "B"\ndate = 2026-01-01\n+++`;
431+
});
432+
433+
const posts = getAllPosts();
434+
435+
expect(posts[0].title).toBe("A");
436+
expect(posts[1].title).toBe("B");
437+
});
438+
});
439+
describe("draft filtering", () => {
440+
it("excludes posts with draft = true from getAllPosts", () => {
441+
fsMocks.existsSync.mockReturnValue(true);
442+
fsMocks.readdirSync.mockReturnValue(["published.md", "draft.md"]);
443+
fsMocks.readFileSync.mockImplementation((filePath: string) => {
444+
if (filePath.includes("published.md")) {
445+
return `+++\ntitle = "Published"\ndate = 2026-01-01\ndraft = false\n+++`;
446+
}
447+
return `+++\ntitle = "Draft"\ndate = 2026-01-02\ndraft = true\n+++`;
448+
});
449+
450+
const posts = getAllPosts();
451+
452+
// Should only find the one published post
453+
expect(posts).toHaveLength(1);
454+
expect(posts[0].title).toBe("Published");
455+
});
456+
457+
it("strictly excludes draft posts from getPostBySlug lookup", () => {
458+
fsMocks.existsSync.mockReturnValue(true);
459+
fsMocks.readdirSync.mockReturnValue(["draft.md"]);
460+
fsMocks.readFileSync.mockReturnValue(`+++
461+
title = "Draft Post"
462+
slug = "draft-post"
463+
date = 2026-01-01
464+
draft = true
465+
+++
466+
Draft content`);
467+
468+
const post = getPostBySlug("draft-post");
469+
470+
// Because data.draft === true, the function hits 'continue' and returns null
471+
expect(post).toBeNull();
472+
});
473+
474+
it("allows retrieving posts via getPostBySlug when draft is false", () => {
475+
fsMocks.existsSync.mockReturnValue(true);
476+
fsMocks.readdirSync.mockReturnValue(["live-post.md"]);
477+
fsMocks.readFileSync.mockReturnValue(`+++
478+
title = "Live Post"
479+
slug = "live-post"
480+
date = 2026-01-01
481+
draft = false
482+
+++
483+
Live content`);
484+
485+
const post = getPostBySlug("live-post");
486+
487+
expect(post).not.toBeNull();
488+
expect(post?.title).toBe("Live Post");
489+
expect(post?.slug).toBe("live-post");
490+
});
491+
});
492+
493+
describe("metadata fallbacks and edge cases", () => {
494+
it("uses FALLBACK_IMAGE when featured_image is missing", () => {
495+
fsMocks.existsSync.mockReturnValue(true);
496+
fsMocks.readdirSync.mockReturnValue(["no-image.md"]);
497+
fsMocks.readFileSync.mockReturnValue(`+++
498+
title = "No Image"
499+
date = 2026-01-01
500+
+++
501+
Content`);
502+
503+
const posts = getAllPosts();
504+
expect(posts[0].featuredImage).toBe(FALLBACK_IMAGE);
505+
});
506+
507+
it("keeps provided featured_image when present", () => {
508+
fsMocks.existsSync.mockReturnValue(true);
509+
fsMocks.readdirSync.mockReturnValue(["with-image.md"]);
510+
fsMocks.readFileSync.mockReturnValue(`+++
511+
title = "With Image"
512+
date = 2026-01-01
513+
featured_image = "/custom.png"
514+
+++
515+
Content`);
516+
517+
const posts = getAllPosts();
518+
expect(posts[0].featuredImage).toBe("/custom.png");
519+
});
520+
521+
it("handles missing description/abstract gracefully (undefined or empty)", () => {
522+
fsMocks.existsSync.mockReturnValue(true);
523+
fsMocks.readdirSync.mockReturnValue(["no-desc.md"]);
524+
fsMocks.readFileSync.mockReturnValue(`+++
525+
title = "No Description"
526+
date = 2026-01-01
527+
+++
528+
Content`);
529+
530+
const posts = getAllPosts();
531+
expect(posts[0].abstract).toBeUndefined();
532+
});
533+
534+
it("handles missing categories and tags as empty arrays", () => {
535+
fsMocks.existsSync.mockReturnValue(true);
536+
fsMocks.readdirSync.mockReturnValue(["no-tax.md"]);
537+
fsMocks.readFileSync.mockReturnValue(`+++
538+
title = "No Categories"
539+
date = 2026-01-01
540+
+++
541+
Content`);
542+
543+
const posts = getAllPosts();
544+
expect(posts[0].categories).toEqual([]);
545+
expect(posts[0].tags).toEqual([]);
546+
});
547+
548+
it("handles missing authors field", () => {
549+
fsMocks.existsSync.mockReturnValue(true);
550+
fsMocks.readdirSync.mockReturnValue(["no-author.md"]);
551+
fsMocks.readFileSync.mockReturnValue(`+++
552+
title = "No Author"
553+
date = 2026-01-01
554+
+++
555+
Content`);
556+
557+
const posts = getAllPosts();
558+
expect(posts[0].authors).toEqual([]);
559+
});
560+
561+
it("skips malformed frontmatter files without crashing", () => {
562+
fsMocks.existsSync.mockReturnValue(true);
563+
fsMocks.readdirSync.mockReturnValue(["good.md", "bad.md"]);
564+
fsMocks.readFileSync.mockImplementation((filePath: string) => {
565+
if (filePath.includes("good.md")) {
566+
return `+++\ntitle = "Good"\ndate = 2026-01-01\n+++`;
567+
}
568+
return `+++\ntitle = "Bad"\ndate = [invalid]\n+++`;
569+
});
570+
571+
const posts = getAllPosts();
572+
expect(posts).toHaveLength(1);
573+
expect(posts[0].title).toBe("Good");
574+
});
575+
576+
it("ignores non-markdown files in posts directory", () => {
577+
fsMocks.existsSync.mockReturnValue(true);
578+
fsMocks.readdirSync.mockReturnValue(["post.md", "image.png", "draft.md"]);
579+
fsMocks.readFileSync.mockImplementation((filePath: string) => {
580+
if (filePath.includes(".md")) {
581+
return `+++\ntitle = "Markdown"\ndate = 2026-01-01\n+++`;
582+
}
583+
throw new Error("Should not read non-md files");
584+
});
585+
586+
const posts = getAllPosts();
587+
expect(posts).toHaveLength(2); // both .md files
588+
});
589+
});
261590
});

0 commit comments

Comments
 (0)