From b34336fe1cf85b8990c78389ec5f8f5015da6bf2 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 30 Aug 2026 13:47:16 +0300 Subject: [PATCH] feat(post): count how many times a post has been quoted Posts carry a quoteCount alongside likeCount and commentCount, so a client can draw the quote badge without counting rows. Denormalised for the same reason the other two are: the read path is far hotter than the write path, and counting the quotes relation would put a subquery on every feed item. Keeping that counter honest makes post creation a multi-step write, so CreatePostUseCase moves onto TransactionPort: the post, the quoted post's existence check and the increment now commit or roll back together. A post that existed without having been counted would leave the badge permanently short with nothing to notice it afterwards. The bot check stays outside the transaction because it only reads, and so do the cache purge and the follower fan-out, which must not hold the write open or roll it back. Deleting a quote gives the count back in the same transaction as the delete. Deleting a quoted post needs no such care: its quotes are cascaded away with it and no surviving row was counting them. Known drift, matching likeCount today: hard-deleting a user cascades their posts away without decrementing the posts those quoted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC --- .../migration.sql | 24 +++++++ prisma/models/post.prisma | 1 + src/core/domain/entities/post.entity.ts | 8 +++ .../domain/interfaces/post-props.interface.ts | 3 + .../ports/repositories/post.repository.ts | 12 ++++ .../post/create-post/create-post.usecase.ts | 63 +++++++++++------ .../post/delete-post/delete-post.usecase.ts | 17 ++++- src/http/plugins/di/use-cases.di.ts | 13 +++- .../types/schemas/post/get-post.schema.ts | 1 + .../persistence/mappers/post-prisma.mapper.ts | 3 + .../repositories/prisma-post.repository.ts | 24 +++++++ tests/e2e/post/create.test.ts | 31 +++++++++ tests/e2e/post/delete.test.ts | 44 ++++++++++++ .../prisma-post.repository.test.ts | 21 ++++++ .../post/create-post.usecase.test.ts | 65 +++++++++++++++++- .../post/delete-post.usecase.test.ts | 68 ++++++++++++++++++- .../mappers/post-prisma.mapper.test.ts | 31 +++++++++ 17 files changed, 402 insertions(+), 27 deletions(-) create mode 100644 prisma/migrations/20260830010000_add_post_quote_count/migration.sql 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.