diff --git a/backend/server/api/social/profile/[userId].get.ts b/backend/server/api/social/profile/[userId].get.ts index 63d66f0..e1602a8 100644 --- a/backend/server/api/social/profile/[userId].get.ts +++ b/backend/server/api/social/profile/[userId].get.ts @@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => { currentUserId = u.id; } catch {} - const [profile, score, followRelation, recentPosts, metaMap] = + const [profile, score, followRelation, recentPosts, metaMap, postsCount, followingCount] = await Promise.all([ getProfile(targetUserId), getUserScore(targetUserId), @@ -38,6 +38,12 @@ export default defineEventHandler(async (event) => { }, }), getUsersMeta([targetUserId]), + usePrisma().communityPost.count({ + where: { userId: targetUserId, isModerated: false }, + }), + usePrisma().userFollow.count({ + where: { followerId: targetUserId }, + }), ]); if (!profile) @@ -52,8 +58,8 @@ export default defineEventHandler(async (event) => { avatar: meta.avatar, bio: (profile as any).bio ?? null, followersCount: profile.followersCount ?? 0, - followingCount: (profile as any).followingCount ?? 0, - postsCount: (profile as any).postsCount ?? 0, + followingCount, + postsCount, tier: score?.tier ?? "beginner", totalPoints: score?.totalPoints ?? 0, isFollowing: !!followRelation, diff --git a/backend/tests/social/profile-counts.test.ts b/backend/tests/social/profile-counts.test.ts new file mode 100644 index 0000000..5c25ddd --- /dev/null +++ b/backend/tests/social/profile-counts.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for GET /api/social/profile/[userId] — postsCount + followingCount + * + * Covers: + * - postsCount reflects live communityPost.count (not 0 when posts exist) + * - followingCount reflects live userFollow.count + * - followersCount passes through from profile row (denormalized) + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + communityPost: { + findMany: vi.fn(), + count: vi.fn(), + }, + userFollow: { + findUnique: vi.fn(), + count: vi.fn(), + }, + profile: { + findUnique: vi.fn(), + }, + userScore: { + findUnique: vi.fn(), + }, +})); + +vi.mock("../../server/utils/prisma", () => ({ + usePrisma: () => ({ + communityPost: mocks.communityPost, + userFollow: mocks.userFollow, + profile: mocks.profile, + userScore: mocks.userScore, + }), +})); + +vi.mock("../../server/utils/auth", () => ({ + requireUser: vi.fn().mockRejectedValue( + Object.assign(new Error("Unauthorized"), { statusCode: 401 }), + ), +})); + +vi.mock("../../server/db/profile", () => ({ + getProfile: vi.fn().mockResolvedValue({ + id: "user-1", + username: "testuser", + followersCount: 3, + createdAt: new Date("2026-01-01T00:00:00Z"), + }), +})); + +vi.mock("../../server/db/scores", () => ({ + getUserScore: vi.fn().mockResolvedValue({ tier: "beginner", totalPoints: 0 }), +})); + +vi.mock("../../server/db/social", () => ({ + getFollowRelation: vi.fn().mockResolvedValue(null), +})); + +vi.mock("../../server/utils/getUsersMeta", () => ({ + getUsersMeta: vi.fn().mockResolvedValue({ + "user-1": { nickname: "Tester", avatar: null }, + }), +})); + +// Stub Nitro globals needed by the endpoint file +const g = globalThis as Record; +if (typeof g.getRouterParam === "undefined") { + g.getRouterParam = (_event: unknown, key: string) => + key === "userId" ? "user-1" : undefined; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.communityPost.findMany.mockResolvedValue([]); + mocks.communityPost.count.mockResolvedValue(0); + mocks.userFollow.count.mockResolvedValue(0); + mocks.userFollow.findUnique.mockResolvedValue(null); +}); + +async function callHandler() { + const mod = await import("../../server/api/social/profile/[userId].get"); + const handler = + typeof mod.default === "function" + ? mod.default + : (mod.default as { handler?: unknown }).handler; + return (handler as (e: unknown) => Promise)({ body: null }); +} + +describe("postsCount", () => { + it("returns postsCount > 0 when user has unmoderated posts", async () => { + mocks.communityPost.count.mockResolvedValueOnce(5); + mocks.userFollow.count.mockResolvedValueOnce(2); + + const result = (await callHandler()) as Record; + + expect(result.postsCount).toBe(5); + expect(mocks.communityPost.count).toHaveBeenCalledWith({ + where: { userId: "user-1", isModerated: false }, + }); + }); + + it("returns postsCount = 0 when user has no posts", async () => { + mocks.communityPost.count.mockResolvedValueOnce(0); + mocks.userFollow.count.mockResolvedValueOnce(0); + + const result = (await callHandler()) as Record; + + expect(result.postsCount).toBe(0); + }); +}); + +describe("followingCount", () => { + it("returns followingCount from userFollow.count", async () => { + mocks.communityPost.count.mockResolvedValueOnce(2); + mocks.userFollow.count.mockResolvedValueOnce(7); + + const result = (await callHandler()) as Record; + + expect(result.followingCount).toBe(7); + expect(mocks.userFollow.count).toHaveBeenCalledWith({ + where: { followerId: "user-1" }, + }); + }); +}); + +describe("followersCount", () => { + it("passes through denormalized followersCount from profile row", async () => { + mocks.communityPost.count.mockResolvedValueOnce(0); + mocks.userFollow.count.mockResolvedValueOnce(0); + + const result = (await callHandler()) as Record; + + expect(result.followersCount).toBe(3); + }); +});