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
13 changes: 13 additions & 0 deletions src/core/ports/repositories/article.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ export interface IArticleRepository {
*/
findById(id: string, currentUserId?: string): Promise<Article | null>;

/**
* Counts the published articles written by one author.
*
* Published only, by design: this number is rendered on a public profile,
* and a total that included drafts would leak both the existence and the
* volume of unpublished work. An author's own draft count comes from the
* meta of GET /articles/me instead.
*
* @param authorId - The author whose articles are counted
* @returns The number of published articles
*/
countPublishedByAuthorId(authorId: string): Promise<number>;

/**
* Deletes an article. Likes, bookmarks and tag links cascade in the schema.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ export interface GetProfileOutput {
isMe: boolean;
isFollowing: boolean;
postCount: number;

/** Published articles written by this user; drafts are never counted */
articleCount: number;
}
12 changes: 11 additions & 1 deletion src/core/use-cases/profile/get-profile/get-profile.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NotFoundError } from "@core/errors/common/not-found.error";
import type { IProfileRepository } from "@core/ports/repositories/profile.repository";
import type { IFollowRepository } from "@core/ports/repositories/follow.repository";
import type { IPostRepository } from "@core/ports/repositories/post.repository";
import type { IArticleRepository } from "@core/ports/repositories/article.repository";
import type { GetProfileOutput } from "./get-profile-usecase.output";

/**
Expand All @@ -16,11 +17,14 @@ export class GetProfileUseCase {
*
* @param profileRepository - Repository for managing profile data
* @param followUserRepository - Repository for managing follow relationships
* @param postRepository - Repository used to count the user's posts
* @param articleRepository - Repository used to count published articles
*/
constructor(
private readonly profileRepository: IProfileRepository,
private readonly followUserRepository: IFollowRepository,
private readonly postRepository: IPostRepository,
private readonly articleRepository: IArticleRepository,
) {}

/**
Expand All @@ -47,21 +51,27 @@ export class GetProfileUseCase {

const isMe = currentUserId ? profile.userId === currentUserId : false;

const [isFollowing, postCount] = await Promise.all([
// The article count is published-only and therefore identical for every
// viewer, including the owner. A viewer-dependent number would be
// unstable and would fork a code path that is otherwise the same for
// everyone.
const [isFollowing, postCount, articleCount] = await Promise.all([
currentUserId && !isMe
? this.followUserRepository.checkIsFollowing(
currentUserId,
profile.userId,
)
: Promise.resolve(false),
this.postRepository.countByUserId(profile.userId),
this.articleRepository.countPublishedByAuthorId(profile.userId),
]);

return {
profile,
isMe,
isFollowing,
postCount,
articleCount,
};
}
}
3 changes: 2 additions & 1 deletion src/http/controllers/profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class ProfileController {

const currentUserId = request.user?.id;

const { profile, isMe, isFollowing, postCount } =
const { profile, isMe, isFollowing, postCount, articleCount } =
await this.getProfileUseCase.execute(username, currentUserId);

const profileData = ProfilePrismaMapper.toResponse(profile);
Expand All @@ -134,6 +134,7 @@ export class ProfileController {
isMe,
isFollowing,
postCount,
articleCount,
avatarUrl: this.getFullImageUrl(profileData.avatarUrl),
bannerUrl: this.getFullImageUrl(profileData.bannerUrl),
},
Expand Down
1 change: 1 addition & 0 deletions src/http/types/schemas/profile/get-profile.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const ProfileItemSchema = FBType.Object({
followersCount: FBType.Number(),
followingCount: FBType.Number(),
postCount: FBType.Number(),
articleCount: FBType.Number(),
isMe: FBType.Boolean(),
isFollowing: FBType.Boolean(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,21 @@ export class PrismaArticleRepository implements IArticleRepository {
: null;
}

/**
* Counts the published articles written by one author.
*
* A single count query rather than a list read: the composite index on
* (author_id, status) covers it exactly.
*
* @param authorId - The author whose articles are counted
* @returns The number of published articles
*/
async countPublishedByAuthorId(authorId: string): Promise<number> {
return await this.prisma.article.count({
where: { authorId, status: ArticleStatus.PUBLISHED },
});
}

/**
* Deletes an article. Likes, bookmarks and tag links cascade in the schema.
*
Expand Down
96 changes: 95 additions & 1 deletion tests/e2e/profile/get-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ describe("GET /profiles/:username - Get Profile", () => {
followersCount: number;
followingCount: number;
postCount: number;
articleCount: number;
isMe: boolean;
isFollowing: boolean;
};
Expand All @@ -81,6 +82,7 @@ describe("GET /profiles/:username - Get Profile", () => {
expect(body.data.followersCount).toEqual(expect.any(Number));
expect(body.data.followingCount).toEqual(expect.any(Number));
expect(body.data.postCount).toEqual(expect.any(Number));
expect(body.data.articleCount).toEqual(expect.any(Number));
expect(body.data.avatarUrl).toEqual(expect.any(String));
expect(body.data.bannerUrl).toEqual(expect.any(String));
expect(body.meta).toHaveProperty("timestamp", expect.any(String));
Expand Down Expand Up @@ -140,4 +142,96 @@ describe("GET /profiles/:username - Get Profile", () => {
expect(body.title).toBe("NotFoundError");
expect(body.detail).toBe("Profile not found.");
});
});


describe("GET /profiles/:username - articleCount", () => {
/**
* Reads the article count off a profile as the given viewer.
*/
const readCount = async (token?: string): Promise<number> => {
const response = token
? await authRequest(token, {
method: "GET",
url: `/profiles/${userA.username}`,
})
: await request({
method: "GET",
url: `/profiles/${userA.username}`,
});
return parseBody<{ data: { articleCount: number } }>(response).data
.articleCount;
};

it("should not count a draft", async () => {
const before = await readCount(tokenA);

await authRequest(tokenA, {
method: "POST",
url: "/articles",
payload: {
title: `Uncounted draft ${Date.now()}`,
body: "Body prose for the draft.",
},
});

expect(await readCount(tokenA)).toBe(before);
});

it("should count a published article", async () => {
const before = await readCount(tokenA);

const created = await authRequest(tokenA, {
method: "POST",
url: "/articles",
payload: {
title: `Counted article ${Date.now()}`,
body: "Body prose for the published article.",
},
});
const { id } = parseBody<{ data: { id: string } }>(created).data;

await authRequest(tokenA, {
method: "POST",
url: `/articles/${id}/publish`,
});

expect(await readCount(tokenA)).toBe(before + 1);
});

it("should report the same count to the owner, a stranger and a guest", async () => {
// Published-only, so a viewer-dependent number would mean the owner
// was being shown how much unpublished work exists.
const asOwner = await readCount(tokenA);
const asStranger = await readCount(tokenB);
const asGuest = await readCount();

expect(asStranger).toBe(asOwner);
expect(asGuest).toBe(asOwner);
});

it("should stop counting an article once it is archived", async () => {
const created = await authRequest(tokenA, {
method: "POST",
url: "/articles",
payload: {
title: `Archivable article ${Date.now()}`,
body: "Body prose.",
},
});
const { id } = parseBody<{ data: { id: string } }>(created).data;

await authRequest(tokenA, {
method: "POST",
url: `/articles/${id}/publish`,
});
const published = await readCount(tokenA);

await authRequest(tokenA, {
method: "POST",
url: `/articles/${id}/archive`,
});

expect(await readCount(tokenA)).toBe(published - 1);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,68 @@ describe("PrismaArticleRepository (integration)", () => {
});
});

describe("countPublishedByAuthorId()", () => {
it("should count only published articles", async () => {
const { articles } = await articleRepo.findAll({
page: 1,
limit: 100,
authorId,
});

const count = await articleRepo.countPublishedByAuthorId(authorId);

expect(count).toBe(articles.length);
expect(count).toBeGreaterThan(0);
});

it("should not count a draft", async () => {
const before = await articleRepo.countPublishedByAuthorId(authorId);

await articleRepo.create(
makeArticle({ title: "Uncounted draft" }),
);

expect(await articleRepo.countPublishedByAuthorId(authorId)).toBe(
before,
);
});

it("should not count an archived article", async () => {
const before = await articleRepo.countPublishedByAuthorId(authorId);

const article = await articleRepo.create(
makeArticle({ title: "Soon archived" }),
);
article.publish();
await articleRepo.update(article);
expect(await articleRepo.countPublishedByAuthorId(authorId)).toBe(
before + 1,
);

article.archive();
await articleRepo.update(article);

expect(await articleRepo.countPublishedByAuthorId(authorId)).toBe(
before,
);
});

it("should not count another author's articles", async () => {
const forOther =
await articleRepo.countPublishedByAuthorId(otherUserId);

expect(forOther).toBe(0);
});

it("should return zero for an unknown author", async () => {
const count = await articleRepo.countPublishedByAuthorId(
"00000000-0000-4000-8000-000000000000",
);

expect(count).toBe(0);
});
});

describe("delete()", () => {
it("should remove the article and cascade its likes", async () => {
const article = await articleRepo.create(
Expand Down
Loading