Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 104 additions & 0 deletions blog/__tests__/handlePosts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import handlePosts, {
filterPostsBySlugs,
filterPostsByTerm,
filterRelatedPosts,
filterRoutablePosts,
slicePosts,
sortPosts,
} from "../core/handlePosts";
Expand Down Expand Up @@ -283,3 +284,106 @@ describe("handlePosts", () => {
expect(result![0].slug).toBe("c");
});
});

// ---------------------------------------------------------------------------
// filterRoutablePosts
// ---------------------------------------------------------------------------
describe("filterRoutablePosts", () => {
it("drops posts with no usable slug", () => {
const posts = [
makePost({ slug: "ok" }),
makePost({ slug: "" }),
makePost({ slug: " " }),
makePost({ slug: undefined as unknown as string }),
makePost({ slug: 42 as unknown as string }),
];

expect(filterRoutablePosts(posts).map((p) => p.slug)).toEqual(["ok"]);
});

it("keeps posts with no status (legacy records) and explicitly published ones", () => {
const posts = [makePost({ slug: "legacy" }), makePost({ slug: "live", status: "published" })];

expect(filterRoutablePosts(posts).map((p) => p.slug)).toEqual(["legacy", "live"]);
});

it("drops every status other than published", () => {
const posts = (["draft", "archived", "generating", "awaiting_review"] as const).map((status) =>
makePost({ slug: status, status }),
);

expect(filterRoutablePosts(posts)).toEqual([]);
});

it("drops an unrecognized status", () => {
const posts = [makePost({ slug: "weird", status: "whatever" as never })];

expect(filterRoutablePosts(posts)).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// date sorting across ISO shapes
// ---------------------------------------------------------------------------
describe("sortPosts date parsing", () => {
it("compares full ISO timestamps, not just the day", () => {
const posts = [
makePost({ slug: "morning", date: "2024-01-01T08:00:00Z" }),
makePost({ slug: "evening", date: "2024-01-01T20:00:00Z" }),
];

expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["evening", "morning"]);
expect(sortPosts(posts, "date_asc").map((p) => p.slug)).toEqual(["morning", "evening"]);
});

it("pins offset-less datetimes to UTC so ordering is timezone independent", () => {
const posts = [
makePost({ slug: "late", date: "2024-01-01T23:30:00" }),
makePost({ slug: "early", date: "2024-01-02T00:30:00" }),
];

expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["early", "late"]);
});

it("mixes bare dates and timestamps on the same axis", () => {
const posts = [
makePost({ slug: "bare", date: "2024-01-02" }),
makePost({ slug: "stamped", date: "2024-01-02T10:00:00Z" }),
];

expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["stamped", "bare"]);
});

it("treats an unparseable date as epoch instead of leaking NaN", () => {
const posts = [
makePost({ slug: "good", date: "2024-01-01" }),
makePost({ slug: "garbage", date: "not-a-date" }),
];

expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["good", "garbage"]);
});
});

// ---------------------------------------------------------------------------
// handlePosts drops unroutable posts before anything else
// ---------------------------------------------------------------------------
describe("handlePosts routability", () => {
it("excludes drafts and slugless posts from every filter path", () => {
const posts = [
makePost({ slug: "live", date: "2024-03-01" }),
makePost({ slug: "draft", status: "draft", date: "2024-04-01" }),
makePost({ slug: "", date: "2024-05-01" }),
];

expect(handlePosts(posts, "date_desc")?.map((p) => p.slug)).toEqual(["live"]);
expect(handlePosts(posts, "date_desc", "news")?.map((p) => p.slug)).toEqual(["live"]);
expect(handlePosts(posts, "date_desc", ["news"])?.map((p) => p.slug)).toEqual(["live"]);
expect(
handlePosts(posts, "date_desc", undefined, undefined, "content")?.map((p) => p.slug),
).toEqual(["live"]);
});

it("returns null when nothing is routable", () => {
expect(handlePosts([makePost({ slug: "d", status: "draft" })], "date_desc")).toBeNull();
});
});
195 changes: 195 additions & 0 deletions blog/__tests__/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ vi.mock("../core/records", () => ({
import { getRecordsByPath } from "../core/records";
import BlogPostItem from "../loaders/BlogPostItem";
import BlogPostPageLoader from "../loaders/BlogPostPage";
import BlogpostList from "../loaders/BlogpostList";
import BlogpostListing from "../loaders/BlogpostListing";
import BlogRelatedPostsLoader from "../loaders/BlogRelatedPosts";
import GetCategories from "../loaders/GetCategories";
Expand Down Expand Up @@ -205,3 +206,197 @@ describe("BlogPostItem", () => {
expect(BlogPostItem({ slug: "nonexistent" })).toBeNull();
});
});

// ---------------------------------------------------------------------------
// Draft / published lifecycle
// ---------------------------------------------------------------------------
describe("publication status", () => {
it("BlogpostListing hides unpublished and slugless posts", () => {
mockGetRecords.mockImplementation((path: string) =>
path.includes("categories")
? []
: [
makePost({ slug: "live", date: "2024-01-01" }),
makePost({ slug: "draft", status: "draft", date: "2024-02-01" }),
makePost({ slug: "", date: "2024-03-01" }),
],
);

const result = BlogpostListing({}, new Request("https://example.com/blog"));
expect(result?.posts.map((p) => p.slug)).toEqual(["live"]);
expect(result?.pageInfo.records).toBe(1);
});

it("BlogPostItem still serves a draft but forces noIndexing", () => {
mockGetRecords.mockReturnValue([makePost({ slug: "draft", status: "draft" })]);

const result = BlogPostItem({ slug: "draft" });
expect(result?.slug).toBe("draft");
expect(result?.seo?.noIndexing).toBe(true);
});

it("BlogPostItem preserves the post's own seo fields while forcing noIndexing", () => {
mockGetRecords.mockReturnValue([
makePost({ slug: "draft", status: "draft", seo: { title: "Kept", noIndexing: false } }),
]);

expect(BlogPostItem({ slug: "draft" })?.seo).toEqual({ title: "Kept", noIndexing: true });
});

it("BlogPostItem leaves a published post untouched", () => {
const post = makePost({ slug: "live", status: "published", seo: { title: "T" } });
mockGetRecords.mockReturnValue([post]);

expect(BlogPostItem({ slug: "live" })).toBe(post);
});

it("BlogPostPage marks a draft noIndexing", () => {
mockGetRecords.mockReturnValue([makePost({ slug: "draft", status: "draft" })]);

const result = BlogPostPageLoader({ slug: "draft" }, new Request("https://example.com/p"));
expect(result?.seo?.noIndexing).toBe(true);
});

it("BlogPostPage leaves a legacy post (no status) indexable", () => {
mockGetRecords.mockReturnValue([makePost({ slug: "legacy" })]);

const result = BlogPostPageLoader({ slug: "legacy" }, new Request("https://example.com/p"));
expect(result?.seo?.noIndexing).toBe(false);
});

it("BlogRelatedPosts hides unpublished posts", () => {
mockGetRecords.mockReturnValue([
makePost({ slug: "live" }),
makePost({ slug: "draft", status: "draft" }),
]);

const result = BlogRelatedPostsLoader({ slug: ["news"] }, new Request("https://example.com/"));
expect(result?.map((p) => p.slug)).toEqual(["live"]);
});
});

// ---------------------------------------------------------------------------
// BlogpostListing categories
// ---------------------------------------------------------------------------
describe("BlogpostListing categories", () => {
const categories: Category[] = [
{ name: "Tech", slug: "tech", description: "All things tech" },
{ name: "News", slug: "news", description: "Fresh news" },
];

function mockWith(cats: unknown[]) {
mockGetRecords.mockImplementation((path: string) =>
path.includes("categories") ? cats : samplePosts,
);
}

it("always returns the sorted category list, even with no active category", () => {
mockWith(categories);

const result = BlogpostListing({}, new Request("https://example.com/blog"));
expect(result?.categories?.map((c) => c.slug)).toEqual(["news", "tech"]);
expect(result?.category).toBeNull();
});

it("resolves the active category from the collection, with description in seo", () => {
mockWith(categories);

const result = BlogpostListing({ slug: "news" }, new Request("https://example.com/blog/news"));
expect(result?.category).toEqual(categories[1]);
expect(result?.seo.title).toBe("News");
expect(result?.seo.description).toBe("Fresh news");
});

it("falls back to the category inlined on a post when the collection has no match", () => {
mockWith([]);

const result = BlogpostListing({ slug: "news" }, new Request("https://example.com/blog/news"));
expect(result?.category).toEqual({ name: "News", slug: "news" });
expect(result?.categories).toEqual([]);
});

it("drops categories missing a name or slug", () => {
mockWith([...categories, { name: "", slug: "empty" }, { name: "No slug" }]);

const result = BlogpostListing({}, new Request("https://example.com/blog"));
expect(result?.categories?.map((c) => c.slug)).toEqual(["news", "tech"]);
});

it("survives a failure reading categories", () => {
mockGetRecords.mockImplementation((path: string) => {
if (path.includes("categories")) throw new Error("boom");
return samplePosts;
});

const result = BlogpostListing({}, new Request("https://example.com/blog"));
expect(result?.posts).toHaveLength(3);
expect(result?.categories).toBeNull();
});
});

// ---------------------------------------------------------------------------
// GetCategories validity filtering
// ---------------------------------------------------------------------------
describe("GetCategories validity", () => {
it("drops categories with a missing or non-string name/slug before sorting", () => {
mockGetRecords.mockReturnValue([
{ name: "Tech", slug: "tech" },
{ name: "", slug: "empty-name" },
{ name: "No slug" },
{ name: 5, slug: "numeric-name" },
{ name: "News", slug: "news" },
]);

expect(GetCategories({})?.map((c) => c.slug)).toEqual(["news", "tech"]);
});

it("returns null when every category is invalid", () => {
mockGetRecords.mockReturnValue([{ name: "" }, { slug: "x" }]);

expect(GetCategories({})).toBeNull();
});

it("filters an invalid category out of a slug lookup too", () => {
mockGetRecords.mockReturnValue([{ name: "", slug: "tech" }]);

expect(GetCategories({ slug: "tech" })).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// BlogpostList
// ---------------------------------------------------------------------------
describe("BlogpostList", () => {
it("returns a flat post array, newest first", () => {
const result = BlogpostList({}, new Request("https://example.com/blog"));
expect(result?.map((p) => p.slug)).toEqual(["post-c", "post-b", "post-a"]);
});

it("honours postSlugs when a category slug is given", () => {
const result = BlogpostList(
{ slug: "news", postSlugs: ["post-a", "post-c"] },
new Request("https://example.com/blog"),
);
expect(result?.map((p) => p.slug)).toEqual(["post-c", "post-a"]);
});

it("paginates with count and page", () => {
const result = BlogpostList({ count: 2, page: 2 }, new Request("https://example.com/blog"));
expect(result?.map((p) => p.slug)).toEqual(["post-a"]);
});

it("returns null when the page is past the end", () => {
expect(BlogpostList({ count: 2, page: 5 }, new Request("https://example.com/blog"))).toBeNull();
});

it("excludes unpublished posts", () => {
mockGetRecords.mockReturnValue([
makePost({ slug: "live" }),
makePost({ slug: "archived", status: "archived" }),
]);

expect(BlogpostList({}, new Request("https://example.com/blog"))?.map((p) => p.slug)).toEqual([
"live",
]);
});
});
Loading
Loading