diff --git a/prisma/migrations/20260830010000_add_post_quote_count/migration.sql b/prisma/migrations/20260830010000_add_post_quote_count/migration.sql new file mode 100644 index 00000000..99634b6d --- /dev/null +++ b/prisma/migrations/20260830010000_add_post_quote_count/migration.sql @@ -0,0 +1,24 @@ +-- How many times a post has been quoted, denormalised the way likeCount and +-- commentCount already are: the read path is far hotter than the write path, +-- and counting the quotes relation per row would put a subquery on every feed +-- item. +-- +-- Adding a NOT NULL column with a constant default is metadata-only in +-- Postgres 11+, so no table rewrite happens here. + +-- AlterTable +ALTER TABLE "public"."posts" ADD COLUMN "quoteCount" INTEGER NOT NULL DEFAULT 0; + +-- Defensive backfill. The quote column and this counter ship in the same +-- deploy, so no quote can predate the counter - but a deploy that landed +-- between the two migrations would leave the counter silently at zero with +-- quotes already in the table. Touches only rows that are actually quoted. +UPDATE "public"."posts" p +SET "quoteCount" = sub.c +FROM ( + SELECT "quoted_post_id" AS id, COUNT(*) AS c + FROM "public"."posts" + WHERE "quoted_post_id" IS NOT NULL + GROUP BY 1 +) sub +WHERE p.id = sub.id; diff --git a/prisma/models/post.prisma b/prisma/models/post.prisma index 27d04dd7..47fb368d 100644 --- a/prisma/models/post.prisma +++ b/prisma/models/post.prisma @@ -37,6 +37,7 @@ model Post { notifications Notification[] commentCount Int @default(0) likeCount Int @default(0) + quoteCount Int @default(0) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/core/domain/entities/post.entity.ts b/src/core/domain/entities/post.entity.ts index 0ae65343..8cc6cbec 100644 --- a/src/core/domain/entities/post.entity.ts +++ b/src/core/domain/entities/post.entity.ts @@ -135,6 +135,14 @@ export class Post { return this.props.commentCount!; } + /** + * Get the number of posts quoting this one + * @returns The quote count, 0 when the post has never been quoted + */ + get quoteCount(): number { + return this.props.quoteCount ?? 0; + } + /** * Indicates whether the current user has bookmarked the post * @returns True if the post is bookmarked by the current user, false otherwise diff --git a/src/core/domain/interfaces/post-props.interface.ts b/src/core/domain/interfaces/post-props.interface.ts index 20f9ca5f..26f0bf35 100644 --- a/src/core/domain/interfaces/post-props.interface.ts +++ b/src/core/domain/interfaces/post-props.interface.ts @@ -62,6 +62,9 @@ export interface PostProps { /** Optional comment count for the post */ commentCount?: number; + /** Optional count of posts quoting this one */ + quoteCount?: number; + /** Indicates if the current authenticated user has bookmarked this post */ isBookmarked?: boolean; diff --git a/src/core/ports/repositories/post.repository.ts b/src/core/ports/repositories/post.repository.ts index 32e9fca7..a7756a81 100644 --- a/src/core/ports/repositories/post.repository.ts +++ b/src/core/ports/repositories/post.repository.ts @@ -61,6 +61,18 @@ export interface IPostRepository { * @param postId - The ID of the post to decrement comment count for */ decrementCommentsCount(postId: string): Promise; + + /** + * Increments the quote count for a post. + * @param postId - The ID of the post that was quoted. + */ + incrementQuoteCount(postId: string): Promise; + + /** + * Decrements the quote count for a post. + * @param postId - The ID of the post whose quote was deleted. + */ + decrementQuoteCount(postId: string): Promise; /** * Finds posts by the author's username with pagination and optional type filtering. * @param username - The username of the author whose posts are being retrieved. diff --git a/src/core/use-cases/post/create-post/create-post.usecase.ts b/src/core/use-cases/post/create-post/create-post.usecase.ts index 58b66776..57e43e6b 100644 --- a/src/core/use-cases/post/create-post/create-post.usecase.ts +++ b/src/core/use-cases/post/create-post/create-post.usecase.ts @@ -1,4 +1,4 @@ -import { type IPostRepository } from "@core/ports/repositories/post.repository"; +import type { TransactionPort } from "@core/ports/services/transaction.port"; import type { CreatePostInput } from "./create-post-usecase.input"; import type { CachePort } from "@core/ports/services/cache.port"; import { Post } from "@core/domain/entities/post.entity"; @@ -19,14 +19,14 @@ export class CreatePostUseCase { /** * Creates a new instance of CreatePostUseCase. * - * @param postRepository - Repository for managing post data + * @param transactionService - Service for running the write atomically * @param cacheService - Service for cache operations * @param userRepository - Repository for managing user data * @param notifyNewPostUseCase - Use case that fans the post out to followers * @param logger - Service for logging operations */ constructor( - private readonly postRepository: IPostRepository, + private readonly transactionService: TransactionPort, private readonly cacheService: CachePort, private readonly userRepository: IUserRepository, private readonly notifyNewPostUseCase: NotifyNewPostUseCase, @@ -46,9 +46,17 @@ export class CreatePostUseCase { * This method creates a new post entity, saves it to the database, * and clears any cached feed data to ensure consistency. * - * A quoted post is resolved before the write so a quote can never be - * stored against an id that is already gone. The foreign key would reject - * it too, but a 404 says what happened and a constraint violation does not. + * The post and the quoted post's counter are written in one transaction: + * a post that exists without having been counted would leave the quote + * badge permanently short, and there is no cheap way to notice afterwards. + * The quoted post is resolved inside that transaction too, so a quote can + * never be stored against an id that is already gone - the foreign key + * would reject it as well, but a 404 says what happened and a constraint + * violation does not. + * + * The bot check stays outside the transaction because it only reads, and + * so do the cache purge and the fan-out, which must not hold the write + * open or roll it back. * * Followers are notified after the post is committed, deliberately * outside the caller's critical path: the post is the thing worth keeping, @@ -65,23 +73,38 @@ export class CreatePostUseCase { } } - if (input.quotedPostId) { - const quoted = await this.postRepository.findById( - input.quotedPostId, - ); - if (!quoted) throw new NotFoundError("Quoted post not found."); - } + const rawPost = await this.transactionService.runInTransaction( + async (ctx) => { + if (input.quotedPostId) { + const quoted = await ctx.postRepository.findById( + input.quotedPostId, + ); + if (!quoted) { + throw new NotFoundError("Quoted post not found."); + } + } + + const post = Post.create( + input.content, + input.type, + input.authorId, + input.mediaUrls || [], + input.categories || [], + input.quotedPostId, + ); + + const created = await ctx.postRepository.create(post); + + if (input.quotedPostId) { + await ctx.postRepository.incrementQuoteCount( + input.quotedPostId, + ); + } - const post = Post.create( - input.content, - input.type, - input.authorId, - input.mediaUrls || [], - input.categories || [], - input.quotedPostId, + return created; + }, ); - const rawPost = await this.postRepository.create(post); await this.cacheService.deleteByPattern("posts:feed:*"); void this.notifyNewPostUseCase diff --git a/src/core/use-cases/post/delete-post/delete-post.usecase.ts b/src/core/use-cases/post/delete-post/delete-post.usecase.ts index 03bdc0d3..5158d454 100644 --- a/src/core/use-cases/post/delete-post/delete-post.usecase.ts +++ b/src/core/use-cases/post/delete-post/delete-post.usecase.ts @@ -4,6 +4,7 @@ import type { LoggerPort } from "@core/ports/services/logger.port"; import { UnauthorizedActionError, NotFoundError } from "@core/errors"; import type { DeletePostUseCaseInput } from "./delete-post-usecase.input"; import type { CachePort } from "@core/ports/services/cache.port"; +import type { TransactionPort } from "@core/ports/services/transaction.port"; /** * Use case for deleting a post. @@ -19,12 +20,14 @@ export class DeletePostUseCase { * @param storageService - Service for file storage operations * @param logger - Service for logging operations * @param cacheService - Service for cache operations + * @param transactionService - Service for running the delete atomically */ constructor( private readonly postRepository: IPostRepository, private readonly storageService: StoragePort, private readonly logger: LoggerPort, private readonly cacheService: CachePort, + private readonly transactionService: TransactionPort, ) {} /** @@ -40,6 +43,11 @@ export class DeletePostUseCase { * This method validates ownership, deletes associated media files, * clears cache entries, and removes the post from the database. * Media deletion errors are logged but don't prevent post deletion. + * + * Deleting a quote also gives the quoted post its count back, in the same + * transaction as the delete so the two cannot come apart. Deleting a + * quoted post needs no such care: its own quotes are cascaded away with + * it, and no surviving row was counting them. */ async execute(input: DeletePostUseCaseInput): Promise { const post = await this.postRepository.findById(input.postId); @@ -72,6 +80,13 @@ export class DeletePostUseCase { } await this.cacheService.deleteByPattern("posts:feed:*"); - await this.postRepository.delete(input.postId); + + await this.transactionService.runInTransaction(async (ctx) => { + if (post.quotedPostId) { + await ctx.postRepository.decrementQuoteCount(post.quotedPostId); + } + + await ctx.postRepository.delete(input.postId); + }); } } diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 7b7fe7df..56cac737 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -321,14 +321,14 @@ export const useCasesModule = { */ createPostUseCase: asFunction( ( - postRepository, + transactionService, cacheService, userRepository, notifyNewPostUseCase, logger, ) => new CreatePostUseCase( - postRepository, + transactionService, cacheService, userRepository, notifyNewPostUseCase, @@ -362,12 +362,19 @@ export const useCasesModule = { * Use case for deleting a post */ deletePostUseCase: asFunction( - (postRepository, storageService, logger, cacheService) => + ( + postRepository, + storageService, + logger, + cacheService, + transactionService, + ) => new DeletePostUseCase( postRepository, storageService, logger, cacheService, + transactionService, ), ).singleton(), diff --git a/src/http/types/schemas/post/get-post.schema.ts b/src/http/types/schemas/post/get-post.schema.ts index a5ede0a9..a4566c55 100644 --- a/src/http/types/schemas/post/get-post.schema.ts +++ b/src/http/types/schemas/post/get-post.schema.ts @@ -34,6 +34,7 @@ export const PostItemSchema = FBType.Object({ createdAt: FBType.String(), likeCount: FBType.Number(), commentCount: FBType.Number(), + quoteCount: FBType.Number(), isLiked: FBType.Boolean(), isBookmarked: FBType.Boolean(), author: PostAuthorSchema, diff --git a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts index 220e32b7..16cefdb4 100644 --- a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts @@ -59,6 +59,7 @@ export interface PostResponse { createdAt: Date; likeCount: number; commentCount: number; + quoteCount: number; author: { id: string; username: string; @@ -103,6 +104,7 @@ export class PostPrismaMapper { updatedAt: dbPost.updatedAt, likeCount: dbPost.likeCount, commentCount: dbPost.commentCount, + quoteCount: dbPost.quoteCount, isLiked: dbPost.likes && dbPost.likes.length > 0, isBookmarked: dbPost.bookmarks && dbPost.bookmarks.length > 0, categories: (dbPost.category as PostCategory[]) || [], @@ -183,6 +185,7 @@ export class PostPrismaMapper { createdAt: post.createdAt, likeCount: post.likeCount || 0, commentCount: post.commentCount || 0, + quoteCount: post.quoteCount || 0, isLiked: post.isLiked || false, isBookmarked: post.isBookmarked || false, author: { diff --git a/src/infrastructure/persistence/repositories/prisma-post.repository.ts b/src/infrastructure/persistence/repositories/prisma-post.repository.ts index 55779730..441e8ab5 100644 --- a/src/infrastructure/persistence/repositories/prisma-post.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-post.repository.ts @@ -221,6 +221,30 @@ export class PrismaPostRepository implements IPostRepository { }); } + /** + * Increments the quote count for a post by its unique identifier. + * @param postId - The unique identifier of the post that was quoted. + * @returns A promise that resolves when the update is complete. + */ + async incrementQuoteCount(postId: string): Promise { + await this.prisma.post.update({ + where: { id: postId }, + data: { quoteCount: { increment: 1 } }, + }); + } + + /** + * Decrements the quote count for a post by its unique identifier. + * @param postId - The unique identifier of the post whose quote was deleted. + * @returns A promise that resolves when the update is complete. + */ + async decrementQuoteCount(postId: string): Promise { + await this.prisma.post.update({ + where: { id: postId }, + data: { quoteCount: { decrement: 1 } }, + }); + } + /** * Finds posts by the author's username with pagination and optional type filtering. * @param username - The username of the author whose posts are being retrieved. diff --git a/tests/e2e/post/create.test.ts b/tests/e2e/post/create.test.ts index 2d667084..cd7fb783 100644 --- a/tests/e2e/post/create.test.ts +++ b/tests/e2e/post/create.test.ts @@ -185,6 +185,37 @@ describe("POST /posts - Create Post", () => { expect(body.data.quotedPost).toBeNull(); }); + it("should count the quote on the post it quotes", async () => { + const targetRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "Counted quote target" }, + }); + const targetId = parseBody<{ + data: { id: string; quoteCount: number }; + }>(targetRes); + + expect(targetId.data.quoteCount).toBe(0); + + await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "Counting quote", + quotedPostId: targetId.data.id, + }, + }); + + const readBack = await request({ + method: "GET", + url: `/posts/${targetId.data.id}`, + }); + const body = parseBody<{ data: { quoteCount: number } }>(readBack); + + expect(readBack.statusCode).toBe(200); + expect(body.data.quoteCount).toBe(1); + }); + it("should return 404 when the quoted post does not exist", async () => { const response = await authRequest(accessToken, { method: "POST", diff --git a/tests/e2e/post/delete.test.ts b/tests/e2e/post/delete.test.ts index 1de4df54..b6a28221 100644 --- a/tests/e2e/post/delete.test.ts +++ b/tests/e2e/post/delete.test.ts @@ -112,4 +112,48 @@ describe("DELETE /posts/:id - Delete Post", () => { expect(response.statusCode).toBe(204); }); + + it("should give the quoted post its count back when the quote is deleted", async () => { + const originalRes = await authRequest(tokenA, { + method: "POST", + url: "/posts", + payload: { content: "Quote counter target" }, + }); + const originalId = parseBody<{ data: { id: string } }>(originalRes).data + .id; + + const quoteRes = await authRequest(tokenA, { + method: "POST", + url: "/posts", + payload: { + content: "Quote to be deleted", + quotedPostId: originalId, + }, + }); + const quoteId = parseBody<{ data: { id: string } }>(quoteRes).data.id; + + const counted = await request({ + method: "GET", + url: `/posts/${originalId}`, + }); + expect( + parseBody<{ data: { quoteCount: number } }>(counted).data + .quoteCount, + ).toBe(1); + + const deleteRes = await authRequest(tokenA, { + method: "DELETE", + url: `/posts/${quoteId}`, + }); + expect(deleteRes.statusCode).toBe(204); + + const recounted = await request({ + method: "GET", + url: `/posts/${originalId}`, + }); + expect( + parseBody<{ data: { quoteCount: number } }>(recounted).data + .quoteCount, + ).toBe(0); + }); }); diff --git a/tests/integration/persistence/repositories/prisma-post.repository.test.ts b/tests/integration/persistence/repositories/prisma-post.repository.test.ts index 7c9a4ca1..6081fc54 100644 --- a/tests/integration/persistence/repositories/prisma-post.repository.test.ts +++ b/tests/integration/persistence/repositories/prisma-post.repository.test.ts @@ -245,6 +245,27 @@ describe("PrismaPostRepository (integration)", () => { expect(readBack?.isQuote()).toBe(false); }); + it("should move the quote counter up and down", async () => { + const original = await postRepo.create( + Post.create("Counted post", PostType.COMMUNITY, testUserId), + ); + + await postRepo.incrementQuoteCount(original.id); + await postRepo.incrementQuoteCount(original.id); + expect((await postRepo.findById(original.id))?.quoteCount).toBe(2); + + await postRepo.decrementQuoteCount(original.id); + expect((await postRepo.findById(original.id))?.quoteCount).toBe(1); + }); + + it("should start a new post at a quote count of zero", async () => { + const created = await postRepo.create( + Post.create("Fresh post", PostType.COMMUNITY, testUserId), + ); + + expect(created.quoteCount).toBe(0); + }); + it("should cascade the delete of an original onto its quotes", async () => { const original = await postRepo.create( Post.create("Doomed original", PostType.COMMUNITY, testUserId), diff --git a/tests/unit/core/use-cases/post/create-post.usecase.test.ts b/tests/unit/core/use-cases/post/create-post.usecase.test.ts index 0f1954f5..b7a4862d 100644 --- a/tests/unit/core/use-cases/post/create-post.usecase.test.ts +++ b/tests/unit/core/use-cases/post/create-post.usecase.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { CreatePostUseCase } from "@core/use-cases/post/create-post"; import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { CachePort } from "@core/ports/services/cache.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; @@ -12,7 +16,12 @@ import { buildUser, buildPost } from "../../../helpers/mock-factories"; describe("CreatePostUseCase", () => { let useCase: CreatePostUseCase; - let postRepository: Pick; + // The transactional repository, reached through the mocked transaction. + let postRepository: Pick< + IPostRepository, + "create" | "findById" | "incrementQuoteCount" + >; + let transactionService: Pick; let userRepository: Pick; let cacheService: Pick; let notifyNewPostUseCase: Pick; @@ -22,6 +31,14 @@ describe("CreatePostUseCase", () => { postRepository = { create: vi.fn().mockResolvedValue(buildPost()), findById: vi.fn().mockResolvedValue(buildPost()), + incrementQuoteCount: vi.fn().mockResolvedValue(undefined), + }; + transactionService = { + runInTransaction: vi + .fn() + .mockImplementation(async (work) => + work({ postRepository } as unknown as TransactionContext), + ), }; userRepository = { findById: vi.fn(), @@ -34,7 +51,7 @@ describe("CreatePostUseCase", () => { }; logger = { error: vi.fn() }; useCase = new CreatePostUseCase( - postRepository as IPostRepository, + transactionService as TransactionPort, cacheService as CachePort, userRepository as IUserRepository, notifyNewPostUseCase as NotifyNewPostUseCase, @@ -202,6 +219,50 @@ describe("CreatePostUseCase", () => { ).toBe(false); }); + it("should count the quote on the post it quotes", async () => { + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ id: "post-0" }), + ); + + await useCase.execute({ + content: "I agree with this", + type: PostType.COMMUNITY, + authorId: "user-1", + quotedPostId: "post-0", + }); + + expect(postRepository.incrementQuoteCount).toHaveBeenCalledWith( + "post-0", + ); + }); + + it("should write the post and the counter in one transaction", async () => { + // A post that exists without having been counted leaves the quote + // badge permanently short, with nothing to notice it afterwards. + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ id: "post-0" }), + ); + + await useCase.execute({ + content: "I agree with this", + type: PostType.COMMUNITY, + authorId: "user-1", + quotedPostId: "post-0", + }); + + expect(transactionService.runInTransaction).toHaveBeenCalledOnce(); + }); + + it("should not touch the counter when nothing is quoted", async () => { + await useCase.execute({ + content: "Just a post", + type: PostType.COMMUNITY, + authorId: "user-1", + }); + + expect(postRepository.incrementQuoteCount).not.toHaveBeenCalled(); + }); + it("should allow quoting a quote", async () => { // Only the read side stops at one level; the write side does not // care how deep the chain already goes. diff --git a/tests/unit/core/use-cases/post/delete-post.usecase.test.ts b/tests/unit/core/use-cases/post/delete-post.usecase.test.ts index 1c5cda65..79f902c2 100644 --- a/tests/unit/core/use-cases/post/delete-post.usecase.test.ts +++ b/tests/unit/core/use-cases/post/delete-post.usecase.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DeletePostUseCase } from "@core/use-cases/post/delete-post"; import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; import type { CachePort } from "@core/ports/services/cache.port"; @@ -10,7 +14,11 @@ import { buildPost } from "../../../helpers/mock-factories"; describe("DeletePostUseCase", () => { let useCase: DeletePostUseCase; - let postRepository: Pick; + let postRepository: Pick< + IPostRepository, + "findById" | "delete" | "decrementQuoteCount" + >; + let transactionService: Pick; let storageService: Pick; let logger: LoggerPort; let cacheService: Pick; @@ -19,6 +27,14 @@ describe("DeletePostUseCase", () => { postRepository = { findById: vi.fn(), delete: vi.fn().mockResolvedValue(undefined), + decrementQuoteCount: vi.fn().mockResolvedValue(undefined), + }; + transactionService = { + runInTransaction: vi + .fn() + .mockImplementation(async (work) => + work({ postRepository } as unknown as TransactionContext), + ), }; storageService = { delete: vi.fn().mockResolvedValue(undefined), @@ -34,6 +50,7 @@ describe("DeletePostUseCase", () => { storageService as StoragePort, logger, cacheService as CachePort, + transactionService as TransactionPort, ); }); @@ -115,4 +132,53 @@ describe("DeletePostUseCase", () => { expect(logger.error).toHaveBeenCalledOnce(); expect(postRepository.delete).toHaveBeenCalledWith("post-1"); }); + + describe("quote counter", () => { + it("should give the quoted post its count back when a quote is deleted", async () => { + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ author: { id: "user-1" }, quotedPostId: "post-0" }), + ); + + await useCase.execute({ + postId: "post-1", + userId: "user-1", + cdnBaseUrl: "https://cdn.example.com", + }); + + expect(postRepository.decrementQuoteCount).toHaveBeenCalledWith( + "post-0", + ); + }); + + it("should delete the post and decrement in one transaction", async () => { + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ author: { id: "user-1" }, quotedPostId: "post-0" }), + ); + + await useCase.execute({ + postId: "post-1", + userId: "user-1", + cdnBaseUrl: "https://cdn.example.com", + }); + + expect(transactionService.runInTransaction).toHaveBeenCalledOnce(); + expect(postRepository.delete).toHaveBeenCalledWith("post-1"); + }); + + it("should not touch any counter when the post quotes nothing", async () => { + // A quoted post needs no decrement of its own: its quotes are + // cascaded away with it and no surviving row was counting them. + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ author: { id: "user-1" } }), + ); + + await useCase.execute({ + postId: "post-1", + userId: "user-1", + cdnBaseUrl: "https://cdn.example.com", + }); + + expect(postRepository.decrementQuoteCount).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts index 3b4aadb2..946332b3 100644 --- a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts +++ b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts @@ -22,6 +22,7 @@ function makeDbPost( category: [], likeCount: 0, commentCount: 0, + quoteCount: 0, createdAt: now, updatedAt: now, author: { @@ -388,6 +389,36 @@ describe("PostPrismaMapper", () => { expect(PostPrismaMapper.toResponse(post, CDN).quotedPost).toBeNull(); }); + it("should map quoteCount onto the entity and the response", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ quoteCount: 3 } as never), + ); + + expect(post.quoteCount).toBe(3); + expect(PostPrismaMapper.toResponse(post, CDN).quoteCount).toBe(3); + }); + + it("should default quoteCount to 0 when the column is absent", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ quoteCount: undefined } as never), + ); + + expect(post.quoteCount).toBe(0); + }); + + it("should keep counters off the quote card", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ + quotedPostId: "post-0", + quotedPost: quotedRelation, + } as never), + ); + const result = PostPrismaMapper.toResponse(post, CDN); + + expect(result.quotedPost).not.toHaveProperty("quoteCount"); + expect(result.quotedPost).not.toHaveProperty("likeCount"); + }); + it("should not nest a second level of quotes in the card", () => { // The include stops at one level, so a quote of a quote carries the // post it quotes and nothing behind it.