Skip to content
Merged
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
128 changes: 118 additions & 10 deletions apps/api/src/routes/content/profiles.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import fastify, { type FastifyInstance } from "fastify";
import profilesRoutes from "./profiles.ts";
import { db } from "@playfulprogramming/db";
import { db, profiles, postAuthors, postData } from "@playfulprogramming/db";
import { and, asc, countDistinct, desc, eq, isNotNull } from "drizzle-orm";

function mockSelectChain(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 leftJoinPostData = vi.fn().mockReturnValue({ groupBy });
const leftJoinPostAuthors = vi
.fn()
.mockReturnValue({ leftJoin: leftJoinPostData });
const from = vi.fn().mockReturnValue({ leftJoin: leftJoinPostAuthors });

vi.mocked(db.select).mockReturnValue({ from } as never);

return {
from,
leftJoinPostAuthors,
leftJoinPostData,
groupBy,
orderBy,
limit,
offset,
};
}

describe("Profiles Routes Tests", () => {
let app: FastifyInstance;
Expand All @@ -15,20 +40,22 @@ describe("Profiles Routes Tests", () => {

describe("/content/profiles", () => {
test("returns all profiles", async () => {
vi.mocked(db.query.profiles.findMany).mockResolvedValue([
mockSelectChain([
{
slug: "crutchcorn",
name: "Corbin Crutchley",
description: "Project lead for Playful Programming.",
profileImage: "content/profile.png",
postsCount: 3,
},
{
slug: "fennifith",
name: "James Fenn",
description: "Backend lead for Playful Programming.",
profileImage: null,
postsCount: 0,
},
] as never);
]);

const response = await app.inject({
method: "GET",
Expand All @@ -43,19 +70,21 @@ describe("Profiles Routes Tests", () => {
"description": "Project lead for Playful Programming.",
"id": "crutchcorn",
"name": "Corbin Crutchley",
"posts": 3,
"profileImageUrl": "https://s3_public_url.test/s3_bucket/content/profile.png",
},
{
"description": "Backend lead for Playful Programming.",
"id": "fennifith",
"name": "James Fenn",
"posts": 0,
},
]
`);
});

test("returns an empty list when there are no profiles", async () => {
vi.mocked(db.query.profiles.findMany).mockResolvedValue([]);
mockSelectChain([]);

const response = await app.inject({
method: "GET",
Expand All @@ -68,7 +97,7 @@ describe("Profiles Routes Tests", () => {
});

test("paginates using page and limit query params", async () => {
vi.mocked(db.query.profiles.findMany).mockResolvedValue([]);
const chain = mockSelectChain([]);

const response = await app.inject({
method: "GET",
Expand All @@ -77,11 +106,90 @@ describe("Profiles Routes Tests", () => {
});

expect(response.statusCode).toBe(200);
expect(db.query.profiles.findMany).toBeCalledWith(
expect.objectContaining({
offset: 20,
limit: 10,
}),
expect(chain.limit).toBeCalledWith(10);
expect(chain.offset).toBeCalledWith(20);
});

test("defaults to sortBy=id, ordering by profile slug ascending", async () => {
const chain = mockSelectChain([]);

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

expect(response.statusCode).toBe(200);
expect(chain.orderBy).toBeCalledWith(asc(profiles.slug));
});

test("sortBy=posts orders authors by descending post count", async () => {
const chain = mockSelectChain([
{
slug: "crutchcorn",
name: "Corbin Crutchley",
description: "Project lead for Playful Programming.",
profileImage: null,
postsCount: 10,
},
{
slug: "fennifith",
name: "James Fenn",
description: "Backend lead for Playful Programming.",
profileImage: null,
postsCount: 4,
},
]);

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

expect(response.statusCode).toBe(200);
expect(chain.orderBy).toBeCalledWith(
desc(countDistinct(postData.slug)),
asc(profiles.slug),
);
expect(
response.json().map((profile: { id: string }) => profile.id),
).toEqual(["crutchcorn", "fennifith"]);
});

test("joins postAuthors on the profile's slug", async () => {
const chain = mockSelectChain([]);

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

expect(response.statusCode).toBe(200);
expect(chain.leftJoinPostAuthors).toBeCalledWith(
postAuthors,
eq(postAuthors.authorSlug, profiles.slug),
);
});

test("only counts posts with a publishedAt date and noindex false", async () => {
const chain = mockSelectChain([]);

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

expect(response.statusCode).toBe(200);
expect(chain.leftJoinPostData).toBeCalledWith(
postData,
and(
eq(postData.slug, postAuthors.postSlug),
isNotNull(postData.publishedAt),
eq(postData.noindex, false),
),
);
});
});
Expand Down
54 changes: 42 additions & 12 deletions apps/api/src/routes/content/profiles.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { FastifyPluginAsync } from "fastify";
import { db } from "@playfulprogramming/db";
import { db, profiles, postAuthors, postData } from "@playfulprogramming/db";
import { and, asc, countDistinct, desc, eq, isNotNull } from "drizzle-orm";
import { Type, type Static } from "typebox";
import { createImageUrl } from "../../utils.ts";

const ProfilesQueryParamsSchema = Type.Object({
page: Type.Number({ minimum: 0 }),
limit: Type.Number({ minimum: 1 }),
sortBy: Type.Union([Type.Literal("id"), Type.Literal("posts")], {
default: "id",
}),
});

const ProfilesResponseSchema = Type.Array(
Expand All @@ -15,6 +19,7 @@ const ProfilesResponseSchema = Type.Array(
name: Type.String(),
description: Type.String(),
profileImageUrl: Type.Optional(Type.String()),
posts: Type.Number(),
},
{
examples: [
Expand All @@ -23,12 +28,14 @@ const ProfilesResponseSchema = Type.Array(
name: "Corbin Crutchley",
description: "Project lead for Playful Programming.",
profileImageUrl: "https://example.test/profile.jpg",
posts: 12,
},
{
id: "fennifith",
name: "James Fenn",
description: "Backend lead for Playful Programming.",
profileImageUrl: "https://example.test/profile.jpg",
posts: 8,
},
],
},
Expand Down Expand Up @@ -61,25 +68,48 @@ const profilesRoutes: FastifyPluginAsync = async (fastify) => {
},
async (request, reply) => {
const queryParams = request.query;
const { sortBy } = queryParams;

const profiles = await db.query.profiles.findMany({
columns: {
slug: true,
name: true,
description: true,
profileImage: true,
},
offset: queryParams.page * queryParams.limit,
limit: queryParams.limit,
});
const profileRows = await db
.select({
slug: profiles.slug,
name: profiles.name,
description: profiles.description,
profileImage: profiles.profileImage,
postsCount: countDistinct(postData.slug),
})
.from(profiles)
.leftJoin(postAuthors, eq(postAuthors.authorSlug, profiles.slug))
.leftJoin(
postData,
and(
eq(postData.slug, postAuthors.postSlug),
isNotNull(postData.publishedAt),
eq(postData.noindex, false),
),
)
.groupBy(
profiles.slug,
profiles.name,
profiles.description,
profiles.profileImage,
)
.orderBy(
...(sortBy === "posts"
? [desc(countDistinct(postData.slug)), asc(profiles.slug)]
: [asc(profiles.slug)]),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.limit(queryParams.limit)
.offset(queryParams.page * queryParams.limit);

const profilesResponse: ProfilesResponse = profiles.map((profile) => ({
const profilesResponse: ProfilesResponse = profileRows.map((profile) => ({
id: profile.slug,
name: profile.name,
description: profile.description,
profileImageUrl: profile.profileImage
? createImageUrl(profile.profileImage)
: undefined,
posts: profile.postsCount,
}));

reply.code(200);
Expand Down
16 changes: 16 additions & 0 deletions apps/api/test-utils/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ vi.mock("@playfulprogramming/redis", () => {

vi.mock("@playfulprogramming/db", () => {
return {
profiles: {
slug: {},
name: {},
description: {},
profileImage: {},
},
postAuthors: {
postSlug: {},
authorSlug: {},
},
postData: {
slug: {},
publishedAt: {},
noindex: {},
},
db: {
query: {
postImages: {
Expand All @@ -39,6 +54,7 @@ vi.mock("@playfulprogramming/db", () => {
findMany: vi.fn(),
},
},
select: vi.fn(),
},
};
});