Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions apps/api/src/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import urlMetadataRoutes from "./routes/tasks/url-metadata.ts";
import authorsRoutes from "./routes/content/authors.ts";
import collectionsRoutes from "./routes/content/collections.ts";
import postRoutes from "./routes/content/post.ts";
import postsRoutes from "./routes/content/posts.ts";
import profilesRoutes from "./routes/content/profiles.ts";
import fastify from "fastify";
import devRoutes from "./routes/dev/index.ts";
Expand All @@ -26,6 +27,7 @@ export const createApp = () => {
app.register(authorsRoutes);
app.register(collectionsRoutes);
app.register(postRoutes);
app.register(postsRoutes);
app.register(profilesRoutes);

if (env.ENVIRONMENT === "development") {
Expand Down
50 changes: 21 additions & 29 deletions apps/api/src/routes/content/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from "fastify";
import { db } from "@playfulprogramming/db";
import { Type, type Static } from "typebox";
import { createImageUrl } from "../../utils.ts";
import { PostBaseSchema } from "./postBaseSchema.ts";

const PostParamsSchema = Type.Object({
slug: Type.String(),
Expand All @@ -11,35 +12,26 @@ const PostQueryParamsSchema = Type.Object({
locale: Type.String({ default: "en" }),
});

const PostResponseSchema = Type.Object(
{
slug: Type.String(),
title: Type.String(),
description: Type.String(),
bannerUrl: Type.Optional(Type.String()),
socialImageUrl: Type.Optional(Type.String()),
wordCount: Type.Number(),
publishedAt: Type.Optional(Type.String({ format: "date-time" })),
authors: Type.Array(
Type.Object({
id: Type.String(),
name: Type.String(),
profileImageUrl: Type.Optional(Type.String()),
}),
),
collection: Type.Optional(
Type.Object({
slug: Type.String(),
title: Type.String(),
chapters: Type.Array(
Type.Object({
slug: Type.String(),
title: Type.String(),
}),
),
}),
),
},
const PostResponseSchema = Type.Intersect(
[
PostBaseSchema,
Type.Object({
description: Type.String(),
socialImageUrl: Type.Optional(Type.String()),
collection: Type.Optional(
Type.Object({
slug: Type.String(),
title: Type.String(),
chapters: Type.Array(
Type.Object({
slug: Type.String(),
title: Type.String(),
}),
),
}),
),
}),
],
{
examples: [
{
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/routes/content/postBaseSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Type } from "typebox";

/** Fields common to every post representation returned by the content API. */
export const PostBaseSchema = Type.Object({
slug: Type.String(),
title: Type.String(),
bannerUrl: Type.Optional(Type.String()),
wordCount: Type.Number(),
publishedAt: Type.Optional(Type.String({ format: "date-time" })),
authors: Type.Array(
Type.Object({
id: Type.String(),
name: Type.String(),
profileImageUrl: Type.Optional(Type.String()),
}),
),
});
284 changes: 284 additions & 0 deletions apps/api/src/routes/content/posts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import fastify, { type FastifyInstance } from "fastify";
import postsRoutes from "./posts.ts";
import { db, postAuthors, postData } from "@playfulprogramming/db";
import { and, asc, desc, eq, isNotNull } from "drizzle-orm";

function mockPostsSelectChain(rows: unknown[]) {
const offset = vi.fn().mockResolvedValue(rows);
const limit = vi.fn().mockReturnValue({ offset });
const orderBy = vi.fn().mockReturnValue({ limit });
const groupBy = vi.fn().mockReturnValue({ orderBy });
const where = vi.fn().mockReturnValue({ groupBy });
const leftJoinPostAuthors = vi.fn().mockReturnValue({ where });
const leftJoinPostTags = vi
.fn()
.mockReturnValue({ leftJoin: leftJoinPostAuthors });
const innerJoinPostData = vi
.fn()
.mockReturnValue({ leftJoin: leftJoinPostTags });
const from = vi.fn().mockReturnValue({ innerJoin: innerJoinPostData });

return {
from,
innerJoinPostData,
leftJoinPostTags,
leftJoinPostAuthors,
where,
groupBy,
orderBy,
limit,
offset,
};
}

function mockAuthorsSelectChain(rows: unknown[]) {
const where = vi.fn().mockResolvedValue(rows);
const innerJoin = vi.fn().mockReturnValue({ where });
const from = vi.fn().mockReturnValue({ innerJoin });

return { from, innerJoin, where };
}

function mockDbSelect(postsRows: unknown[], authorRows: unknown[] = []) {
const postsChain = mockPostsSelectChain(postsRows);
const authorsChain = mockAuthorsSelectChain(authorRows);

const selectMock = vi.mocked(db.select);
selectMock.mockReturnValueOnce({ from: postsChain.from } as never);
if (postsRows.length > 0) {
selectMock.mockReturnValueOnce({ from: authorsChain.from } as never);
}

return { postsChain, authorsChain };
}

describe("Posts Routes Tests", () => {
let app: FastifyInstance;
beforeAll(async () => {
app = fastify();
await app.register(postsRoutes);
});

afterAll(async () => {
await app.close();
});

describe("/content/posts", () => {
test("returns posts with their authors and tags", async () => {
mockDbSelect(
[
{
slug: "example-post",
title: "Example Post",
bannerImage: "content/banner.png",
wordCount: 1200,
publishedAt: new Date("2024-01-15T00:00:00Z"),
tags: ["react", "javascript"],
},
],
[
{
postSlug: "example-post",
id: "crutchcorn",
name: "Corbin Crutchley",
profileImage: "content/profile.png",
},
],
);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10" },
});

expect(response.statusCode).toBe(200);
expect(response.json()).toMatchInlineSnapshot(`
[
{
"authors": [
{
"id": "crutchcorn",
"name": "Corbin Crutchley",
"profileImageUrl": "https://s3_public_url.test/s3_bucket/content/profile.png",
},
],
"bannerUrl": "https://s3_public_url.test/s3_bucket/content/banner.png",
"publishedAt": "2024-01-15T00:00:00.000Z",
"slug": "example-post",
"tags": [
"react",
"javascript",
],
"title": "Example Post",
"wordCount": 1200,
},
]
`);
});

test("does not include a description or excerpt field", async () => {
mockDbSelect([
{
slug: "example-post",
title: "Example Post",
bannerImage: null,
wordCount: 300,
publishedAt: new Date("2024-01-15T00:00:00Z"),
tags: [],
},
]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10" },
});

expect(response.statusCode).toBe(200);
const [post] = response.json();
expect(post).not.toHaveProperty("description");
expect(post).not.toHaveProperty("excerpt");
});

test("groups multiple co-authors under the same post", async () => {
mockDbSelect(
[
{
slug: "example-post",
title: "Example Post",
bannerImage: null,
wordCount: 300,
publishedAt: new Date("2024-01-15T00:00:00Z"),
tags: [],
},
],
[
{
postSlug: "example-post",
id: "crutchcorn",
name: "Corbin Crutchley",
profileImage: null,
},
{
postSlug: "example-post",
id: "fennifith",
name: "James Fenn",
profileImage: null,
},
],
);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10" },
});

expect(response.statusCode).toBe(200);
expect(
response.json()[0].authors.map((a: { id: string }) => a.id),
).toEqual(["crutchcorn", "fennifith"]);
});

test("returns an empty list when there are no posts", async () => {
mockDbSelect([]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10" },
});

expect(response.statusCode).toBe(200);
expect(response.json()).toMatchInlineSnapshot(`[]`);
});

test("paginates using page and limit query params", async () => {
const { postsChain } = mockDbSelect([]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "2", limit: "10" },
});

expect(response.statusCode).toBe(200);
expect(postsChain.limit).toBeCalledWith(10);
expect(postsChain.offset).toBeCalledWith(20);
});

test("defaults to sort=newest, ordering by publishedAt descending", async () => {
const { postsChain } = mockDbSelect([]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10" },
});

expect(response.statusCode).toBe(200);
expect(postsChain.orderBy).toBeCalledWith(desc(postData.publishedAt));
});

test("sort=oldest orders by publishedAt ascending", async () => {
const { postsChain } = mockDbSelect([]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10", sort: "oldest" },
});

expect(response.statusCode).toBe(200);
expect(postsChain.orderBy).toBeCalledWith(asc(postData.publishedAt));
});

test("only includes posts with a publishedAt date and noindex false", async () => {
const { postsChain } = mockDbSelect([]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10" },
});

expect(response.statusCode).toBe(200);
expect(postsChain.where).toBeCalledWith(
and(isNotNull(postData.publishedAt), eq(postData.noindex, false)),
);
});

test("author filter matches co-authors via the postAuthors join", async () => {
const { postsChain } = mockDbSelect([]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10", author: "crutchcorn" },
});

expect(response.statusCode).toBe(200);
expect(postsChain.where).toBeCalledWith(
and(
isNotNull(postData.publishedAt),
eq(postData.noindex, false),
eq(postAuthors.authorSlug, "crutchcorn"),
),
);
});

test("returns an empty list for an author with no posts", async () => {
mockDbSelect([]);

const response = await app.inject({
method: "GET",
url: "/content/posts",
query: { page: "0", limit: "10", author: "non-existent-author" },
});

expect(response.statusCode).toBe(200);
expect(response.json()).toMatchInlineSnapshot(`[]`);
});
});
});
Loading