From 47e576b830f6386c96af79e29524317cf24a5de5 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 30 Aug 2026 14:03:05 +0300 Subject: [PATCH] feat(notification): tell an author when their post is quoted Being quoted is a louder signal than a like - somebody has said something about your post to their own followers - and until now it arrived silently, as a number going up. The notification leads to the quote rather than to the post being quoted: the recipient already knows their own post, what they want to open is what was said about it, and the quote carries the original as its card anyway. That also buys the cleanup for free, since Notification.post cascades - deleting the quote takes its notification with it, with no undo path to write. NotifyQuotedAuthorUseCase sits beside NotifyNewPostUseCase and resolves the quoted author itself, so post creation gains one collaborator rather than two and its transaction keeps its shape. It runs after the commit and fire-and-forget: the post is the thing worth keeping, so a notification failure is logged rather than allowed to fail the request or roll the write back. An account quoting itself notifies nobody. First enum change in the repository; the migration carries the note about ALTER TYPE ... ADD VALUE inside Prisma's migration transaction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC --- .../migration.sql | 11 ++ prisma/models/notification.prisma | 1 + .../domain/enums/notification-type.enum.ts | 5 + .../notify-quoted-author/index.ts | 2 + .../notify-quoted-author.input.ts | 13 ++ .../notify-quoted-author.usecase.ts | 73 ++++++++ .../post/create-post/create-post.usecase.ts | 25 ++- src/http/plugins/di/use-cases.di.ts | 8 + tests/e2e/notification/quote.test.ts | 164 ++++++++++++++++++ tests/unit/core/domain/enums/enums.test.ts | 8 +- .../notify-quoted-author.usecase.test.ts | 101 +++++++++++ .../post/create-post.usecase.test.ts | 61 +++++++ 12 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260830020000_add_quote_notification_type/migration.sql create mode 100644 src/core/use-cases/notification/notify-quoted-author/index.ts create mode 100644 src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.input.ts create mode 100644 src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.usecase.ts create mode 100644 tests/e2e/notification/quote.test.ts create mode 100644 tests/unit/core/use-cases/notification/notify-quoted-author.usecase.test.ts diff --git a/prisma/migrations/20260830020000_add_quote_notification_type/migration.sql b/prisma/migrations/20260830020000_add_quote_notification_type/migration.sql new file mode 100644 index 0000000..0db7f68 --- /dev/null +++ b/prisma/migrations/20260830020000_add_quote_notification_type/migration.sql @@ -0,0 +1,11 @@ +-- Quoting someone's post now notifies them. +-- +-- Postgres refuses ALTER TYPE ... ADD VALUE inside a transaction block before +-- version 12, and Prisma runs every migration in one. From 12 onwards it is +-- allowed, with the single condition that the new value is not *used* in the +-- same transaction - this migration only adds it, so nothing here writes a +-- QUOTE row. This is the first enum change in the repository; the reasoning +-- lives here so the next one does not have to rediscover it. + +-- AlterEnum +ALTER TYPE "public"."NotificationType" ADD VALUE 'QUOTE'; diff --git a/prisma/models/notification.prisma b/prisma/models/notification.prisma index e3275cd..7992ad3 100644 --- a/prisma/models/notification.prisma +++ b/prisma/models/notification.prisma @@ -5,6 +5,7 @@ enum NotificationType { LIKE COMMENT_LIKE COMMENT_REPLY + QUOTE } model Notification { diff --git a/src/core/domain/enums/notification-type.enum.ts b/src/core/domain/enums/notification-type.enum.ts index 8a39dc0..904d187 100644 --- a/src/core/domain/enums/notification-type.enum.ts +++ b/src/core/domain/enums/notification-type.enum.ts @@ -30,4 +30,9 @@ export enum NotificationType { * A reply to one of the user's comments */ COMMENT_REPLY = "COMMENT_REPLY", + + /** + * Notification when a user quotes another user's post + */ + QUOTE = "QUOTE", } diff --git a/src/core/use-cases/notification/notify-quoted-author/index.ts b/src/core/use-cases/notification/notify-quoted-author/index.ts new file mode 100644 index 0000000..b8176bc --- /dev/null +++ b/src/core/use-cases/notification/notify-quoted-author/index.ts @@ -0,0 +1,2 @@ +export { NotifyQuotedAuthorUseCase } from "./notify-quoted-author.usecase"; +export type { NotifyQuotedAuthorInput } from "./notify-quoted-author.input"; diff --git a/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.input.ts b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.input.ts new file mode 100644 index 0000000..288fd7a --- /dev/null +++ b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.input.ts @@ -0,0 +1,13 @@ +/** + * Input for the NotifyQuotedAuthorUseCase. + */ +export interface NotifyQuotedAuthorInput { + /** The post that was just published as a quote, and where the notification leads. */ + quotePostId: string; + + /** The post it quotes, whose author is notified. */ + quotedPostId: string; + + /** The account that published the quote, and the issuer of the notification. */ + issuerId: string; +} diff --git a/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.usecase.ts b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.usecase.ts new file mode 100644 index 0000000..fbecc35 --- /dev/null +++ b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.usecase.ts @@ -0,0 +1,73 @@ +import { Notification } from "@core/domain/entities/notification.entity"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { NotifyQuotedAuthorInput } from "./notify-quoted-author.input"; + +/** + * Use case for telling an author that one of their posts was quoted. + * + * Kept separate from post creation for the same reason as + * {@link NotifyNewPostUseCase}: a single caller-agnostic entry point, so + * moving it onto a queue later is a change of caller rather than a rewrite. + */ +export class NotifyQuotedAuthorUseCase { + /** + * Creates a new instance of NotifyQuotedAuthorUseCase. + * + * @param postRepository - Repository used to resolve the quoted post's author + * @param notificationRepository - Repository for persisting the notification + * @param realtimeService - Service for pushing the notification live + */ + constructor( + private readonly postRepository: IPostRepository, + private readonly notificationRepository: INotificationRepository, + private readonly realtimeService: RealtimePort, + ) {} + + /** + * Notifies the author of the quoted post. + * + * @param input - The new quote, the post it quotes, and who published it + * @returns Promise The number of people notified: 1, or 0 + * + * @remarks + * The notification points at the **quote**, not at the post being quoted: + * the recipient already knows their own post, what they want to open is + * what somebody said about it, and the quote carries the original as its + * card anyway. That also buys the cleanup for free - `Notification.post` + * cascades, so deleting the quote takes its notification with it. + * + * Returns 0 without writing anything when the quoted post is already gone + * (a narrow race between the commit and this call) or when an account + * quotes itself, which is not news to anyone. + */ + async execute(input: NotifyQuotedAuthorInput): Promise { + const quotedPost = await this.postRepository.findById( + input.quotedPostId, + ); + if (!quotedPost) return 0; + + const recipientId = quotedPost.author.id; + if (recipientId === input.issuerId) return 0; + + await this.notificationRepository.create( + Notification.create( + recipientId, + input.issuerId, + NotificationType.QUOTE, + { postId: input.quotePostId }, + ), + ); + + this.realtimeService.emitToUser(recipientId, "new-notification", { + type: NotificationType.QUOTE, + issuerId: input.issuerId, + postId: input.quotePostId, + referenceId: input.quotePostId, + }); + + return 1; + } +} 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 57e43e6..11a562b 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 @@ -5,6 +5,7 @@ import { Post } from "@core/domain/entities/post.entity"; import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { LoggerPort } from "@core/ports/services/logger.port"; import type { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; +import type { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import { PostType } from "@core/domain/enums"; import { ForbiddenError } from "@core/errors/common/forbidden.error"; import { NotFoundError } from "@core/errors/common/not-found.error"; @@ -23,6 +24,7 @@ export class CreatePostUseCase { * @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 notifyQuotedAuthorUseCase - Use case that tells an author their post was quoted * @param logger - Service for logging operations */ constructor( @@ -30,6 +32,7 @@ export class CreatePostUseCase { private readonly cacheService: CachePort, private readonly userRepository: IUserRepository, private readonly notifyNewPostUseCase: NotifyNewPostUseCase, + private readonly notifyQuotedAuthorUseCase: NotifyQuotedAuthorUseCase, private readonly logger: LoggerPort, ) {} @@ -58,9 +61,10 @@ export class CreatePostUseCase { * 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, - * so a fan-out failure is logged rather than allowed to fail the request. + * Followers and, for a quote, the quoted author are notified after the + * post is committed, deliberately outside the caller's critical path: the + * post is the thing worth keeping, so a notification failure is logged + * rather than allowed to fail the request or roll the write back. */ async execute(input: CreatePostInput): Promise { if ([PostType.SYSTEM_UPDATE, PostType.TECH_NEWS].includes(input.type)) { @@ -120,6 +124,21 @@ export class CreatePostUseCase { ); }); + if (input.quotedPostId) { + void this.notifyQuotedAuthorUseCase + .execute({ + quotePostId: rawPost.id, + quotedPostId: input.quotedPostId, + issuerId: input.authorId, + }) + .catch((err: unknown) => { + this.logger.error( + { err, postId: rawPost.id }, + "Failed to notify the quoted author", + ); + }); + } + return rawPost; } } diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 56cac73..aff5b0c 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -35,6 +35,7 @@ import { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/ import { PurgeExpiredNotificationsUseCase } from "@core/use-cases/notification/purge-expired"; import { CreatePostUseCase } from "@core/use-cases/post/create-post"; import { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; +import { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media"; import { GetPostsUseCase } from "@core/use-cases/post/get-posts"; import { DeletePostUseCase } from "@core/use-cases/post/delete-post"; @@ -325,6 +326,7 @@ export const useCasesModule = { cacheService, userRepository, notifyNewPostUseCase, + notifyQuotedAuthorUseCase, logger, ) => new CreatePostUseCase( @@ -332,6 +334,7 @@ export const useCasesModule = { cacheService, userRepository, notifyNewPostUseCase, + notifyQuotedAuthorUseCase, logger, ), ).singleton(), @@ -341,6 +344,11 @@ export const useCasesModule = { */ notifyNewPostUseCase: asClass(NotifyNewPostUseCase).singleton(), + /** + * Use case for telling an author that one of their posts was quoted + */ + notifyQuotedAuthorUseCase: asClass(NotifyQuotedAuthorUseCase).singleton(), + /** * Use case for uploading post media files */ diff --git a/tests/e2e/notification/quote.test.ts b/tests/e2e/notification/quote.test.ts new file mode 100644 index 0000000..a38119c --- /dev/null +++ b/tests/e2e/notification/quote.test.ts @@ -0,0 +1,164 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +interface NotificationItem { + id: string; + type: string; + issuerId: string; + // Both are Optional in the response schema, so the key is absent rather + // than null on a notification that points at nothing. + postId?: string; + referenceId?: string; + isRead: boolean; +} + +/** + * E2E tests for QUOTE notifications. + * + * Being quoted is a louder signal than a like: somebody has said something + * about your post to their own followers. The notification leads to the + * quote rather than to the post being quoted, because the recipient already + * knows their own post. + */ +describe("QUOTE notifications", () => { + const ts = Date.now(); + const author = { + email: `nq-author-${ts}@test.com`, + password: "password123", + username: `nqa${ts}`, + }; + const quoter = { + email: `nq-quoter-${ts}@test.com`, + password: "password123", + username: `nqq${ts}`, + }; + + let authorToken = ""; + let quoterToken = ""; + let quoterId = ""; + let originalPostId = ""; + + async function login(user: { + email: string; + password: string; + }): Promise { + const response = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + return parseBody<{ data: { accessToken: string } }>(response).data + .accessToken; + } + + async function notificationsOf(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications?page=1&limit=50", + }); + return parseBody<{ data: NotificationItem[] }>(response).data; + } + + async function unreadCountOf(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications/unread-count", + }); + return parseBody<{ data: { count: number } }>(response).data.count; + } + + async function createPost( + token: string, + payload: Record, + ): Promise { + const response = await authRequest(token, { + method: "POST", + url: "/posts", + payload, + }); + expect(response.statusCode).toBe(201); + return parseBody<{ data: { id: string } }>(response).data.id; + } + + /** + * Registers an author and a quoter, and leaves the author with one post + * for the quoter to point at. + */ + beforeAll(async () => { + await request({ + method: "POST", + url: "/auth/register", + payload: author, + }); + const quoterRes = await request({ + method: "POST", + url: "/auth/register", + payload: quoter, + }); + quoterId = parseBody<{ data: { id: string } }>(quoterRes).data.id; + + authorToken = await login(author); + quoterToken = await login(quoter); + + originalPostId = await createPost(authorToken, { + content: "A post worth quoting", + }); + }); + + it("should notify the author, pointing at the quote", async () => { + const before = await unreadCountOf(authorToken); + + const quoteId = await createPost(quoterToken, { + content: "Adding my two cents", + quotedPostId: originalPostId, + }); + + const notifications = await notificationsOf(authorToken); + const quoteNotification = notifications.find( + (item) => item.type === "QUOTE" && item.postId === quoteId, + ); + + expect(quoteNotification).toBeDefined(); + expect(quoteNotification?.issuerId).toBe(quoterId); + expect(quoteNotification?.referenceId).toBe(quoteId); + expect(quoteNotification?.isRead).toBe(false); + expect(await unreadCountOf(authorToken)).toBe(before + 1); + }); + + it("should not notify the quoter about their own quote", async () => { + const notifications = await notificationsOf(quoterToken); + + expect(notifications.some((item) => item.type === "QUOTE")).toBe(false); + }); + + it("should stay silent when an account quotes itself", async () => { + const before = await unreadCountOf(authorToken); + + await createPost(authorToken, { + content: "Quoting myself", + quotedPostId: originalPostId, + }); + + expect(await unreadCountOf(authorToken)).toBe(before); + }); + + it("should take the notification with the quote when it is deleted", async () => { + // Notification.post cascades, so nothing has to clean this up by hand. + const quoteId = await createPost(quoterToken, { + content: "A quote that will not last", + quotedPostId: originalPostId, + }); + + const notified = await notificationsOf(authorToken); + expect(notified.some((item) => item.postId === quoteId)).toBe(true); + + const deleteRes = await authRequest(quoterToken, { + method: "DELETE", + url: `/posts/${quoteId}`, + }); + expect(deleteRes.statusCode).toBe(204); + + const after = await notificationsOf(authorToken); + expect(after.some((item) => item.postId === quoteId)).toBe(false); + }); +}); diff --git a/tests/unit/core/domain/enums/enums.test.ts b/tests/unit/core/domain/enums/enums.test.ts index 3058502..f1b7011 100644 --- a/tests/unit/core/domain/enums/enums.test.ts +++ b/tests/unit/core/domain/enums/enums.test.ts @@ -78,8 +78,12 @@ describe("Domain Enums", () => { expect(NotificationType.COMMENT_REPLY).toBe("COMMENT_REPLY"); }); - it("should have exactly 6 values", () => { - expect(Object.keys(NotificationType)).toHaveLength(6); + it("should have QUOTE value", () => { + expect(NotificationType.QUOTE).toBe("QUOTE"); + }); + + it("should have exactly 7 values", () => { + expect(Object.keys(NotificationType)).toHaveLength(7); }); }); diff --git a/tests/unit/core/use-cases/notification/notify-quoted-author.usecase.test.ts b/tests/unit/core/use-cases/notification/notify-quoted-author.usecase.test.ts new file mode 100644 index 0000000..e352b8b --- /dev/null +++ b/tests/unit/core/use-cases/notification/notify-quoted-author.usecase.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { buildPost } from "../../../helpers/mock-factories"; + +describe("NotifyQuotedAuthorUseCase", () => { + let useCase: NotifyQuotedAuthorUseCase; + let postRepository: Pick; + let notificationRepository: Pick; + let realtimeService: RealtimePort; + + const input = { + quotePostId: "quote-1", + quotedPostId: "post-0", + issuerId: "user-2", + }; + + beforeEach(() => { + postRepository = { + findById: vi + .fn() + .mockResolvedValue( + buildPost({ id: "post-0", author: { id: "user-1" } }), + ), + }; + notificationRepository = { + create: vi.fn().mockResolvedValue(undefined), + }; + realtimeService = { emitToUser: vi.fn() }; + useCase = new NotifyQuotedAuthorUseCase( + postRepository as IPostRepository, + notificationRepository as INotificationRepository, + realtimeService, + ); + }); + + it("should notify the author of the quoted post", async () => { + const result = await useCase.execute(input); + + expect(result).toBe(1); + expect(notificationRepository.create).toHaveBeenCalledOnce(); + + const notification = vi.mocked(notificationRepository.create).mock + .calls[0][0]; + expect(notification.recipientId).toBe("user-1"); + expect(notification.issuerId).toBe("user-2"); + expect(notification.type).toBe(NotificationType.QUOTE); + }); + + it("should point the notification at the quote, not at the post it quotes", async () => { + // The recipient already knows their own post; what they want to open + // is what somebody said about it. + await useCase.execute(input); + + const notification = vi.mocked(notificationRepository.create).mock + .calls[0][0]; + expect(notification.postId).toBe("quote-1"); + expect(notification.referenceId).toBe("quote-1"); + }); + + it("should push the same target over realtime", async () => { + await useCase.execute(input); + + expect(realtimeService.emitToUser).toHaveBeenCalledWith( + "user-1", + "new-notification", + { + type: NotificationType.QUOTE, + issuerId: "user-2", + postId: "quote-1", + referenceId: "quote-1", + }, + ); + }); + + it("should stay silent when an account quotes itself", async () => { + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ id: "post-0", author: { id: "user-2" } }), + ); + + const result = await useCase.execute(input); + + expect(result).toBe(0); + expect(notificationRepository.create).not.toHaveBeenCalled(); + expect(realtimeService.emitToUser).not.toHaveBeenCalled(); + }); + + it("should stay silent when the quoted post is already gone", async () => { + // A narrow race between the post committing and this call. + vi.mocked(postRepository.findById).mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result).toBe(0); + expect(notificationRepository.create).not.toHaveBeenCalled(); + expect(realtimeService.emitToUser).not.toHaveBeenCalled(); + }); +}); 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 b7a4862..66f5acd 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 @@ -9,6 +9,7 @@ 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"; import type { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; +import type { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import { NotFoundError } from "@core/errors/common/not-found.error"; import { ForbiddenError } from "@core/errors/common/forbidden.error"; import { PostType } from "@core/domain/enums/post-type.enum"; @@ -25,6 +26,7 @@ describe("CreatePostUseCase", () => { let userRepository: Pick; let cacheService: Pick; let notifyNewPostUseCase: Pick; + let notifyQuotedAuthorUseCase: Pick; let logger: Pick; beforeEach(() => { @@ -49,12 +51,16 @@ describe("CreatePostUseCase", () => { notifyNewPostUseCase = { execute: vi.fn().mockResolvedValue(0), }; + notifyQuotedAuthorUseCase = { + execute: vi.fn().mockResolvedValue(0), + }; logger = { error: vi.fn() }; useCase = new CreatePostUseCase( transactionService as TransactionPort, cacheService as CachePort, userRepository as IUserRepository, notifyNewPostUseCase as NotifyNewPostUseCase, + notifyQuotedAuthorUseCase as NotifyQuotedAuthorUseCase, logger as LoggerPort, ); }); @@ -263,6 +269,61 @@ describe("CreatePostUseCase", () => { expect(postRepository.incrementQuoteCount).not.toHaveBeenCalled(); }); + it("should tell the quoted author about it", async () => { + const created = buildPost({ id: "quote-1" }); + vi.mocked(postRepository.create).mockResolvedValue(created); + 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", + }); + + await vi.waitFor(() => { + expect(notifyQuotedAuthorUseCase.execute).toHaveBeenCalledWith({ + quotePostId: "quote-1", + quotedPostId: "post-0", + issuerId: "user-1", + }); + }); + }); + + it("should not notify anyone when nothing is quoted", async () => { + await useCase.execute({ + content: "Just a post", + type: PostType.COMMUNITY, + authorId: "user-1", + }); + + expect(notifyQuotedAuthorUseCase.execute).not.toHaveBeenCalled(); + }); + + it("should still return the post when the quote notification fails", async () => { + // The post is the thing worth keeping; a notification failure must + // not surface as a failed request. + const created = buildPost({ id: "quote-1" }); + vi.mocked(postRepository.create).mockResolvedValue(created); + vi.mocked(notifyQuotedAuthorUseCase.execute).mockRejectedValue( + new Error("notifier exploded"), + ); + + const result = await useCase.execute({ + content: "I agree with this", + type: PostType.COMMUNITY, + authorId: "user-1", + quotedPostId: "post-0", + }); + + expect(result).toBe(created); + await vi.waitFor(() => { + expect(logger.error).toHaveBeenCalledOnce(); + }); + }); + 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.