From 08f85f981eec782769744c4c684f8897c2d5bf3e Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 30 Aug 2026 09:26:40 +0300 Subject: [PATCH] feat(post): embed a quoted post in a post A post can now quote another post, the way a quote tweet does: POST /posts takes an optional quotedPostId and every post response carries a quotedPost card, or null. The relation is self-referential on Post and cascades, so deleting a post deletes every post that quotes it and a card can never point at content that is gone. The card is deliberately lean - author, content, media and date, no counters and no viewer-specific flags - so embedding one costs no extra joins, and the include stops at one level, so a quote of a quote carries the post it quotes and nothing behind it. The quoted post is resolved before the write, so quoting an id that is already gone answers 404 rather than a constraint violation. The feed cache revives the nested date by hand: without it the same request would answer with a Date on a miss and a string for the next 60 seconds. Counters, notifications and a list of quoters follow in their own changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC --- .../migration.sql | 22 +++ prisma/models/post.prisma | 8 ++ src/core/domain/entities/post.entity.ts | 29 ++++ .../domain/interfaces/post-props.interface.ts | 17 +++ .../interfaces/quoted-post.interface.ts | 37 +++++ .../create-post/create-post-usecase.input.ts | 8 ++ .../post/create-post/create-post.usecase.ts | 18 ++- .../post/get-posts/get-posts.usecase.ts | 23 ++++ src/http/controllers/post.controller.ts | 4 +- .../types/schemas/post/create-post.schema.ts | 6 + .../types/schemas/post/get-post.schema.ts | 17 +++ .../persistence/mappers/post-prisma.mapper.ts | 126 +++++++++++++++++- .../repositories/prisma-post.repository.ts | 69 +++++----- tests/e2e/post/create.test.ts | 122 +++++++++++++++++ tests/e2e/post/get-feed.test.ts | 47 +++++++ .../prisma-post.repository.test.ts | 95 +++++++++++++ .../post/create-post.usecase.test.ts | 69 +++++++++- .../use-cases/post/get-posts.usecase.test.ts | 37 +++++ .../mappers/post-prisma.mapper.test.ts | 97 ++++++++++++++ 19 files changed, 805 insertions(+), 46 deletions(-) create mode 100644 prisma/migrations/20260830000000_add_post_quotes/migration.sql create mode 100644 src/core/domain/interfaces/quoted-post.interface.ts diff --git a/prisma/migrations/20260830000000_add_post_quotes/migration.sql b/prisma/migrations/20260830000000_add_post_quotes/migration.sql new file mode 100644 index 00000000..a73b4acb --- /dev/null +++ b/prisma/migrations/20260830000000_add_post_quotes/migration.sql @@ -0,0 +1,22 @@ +-- Quote posts: a post can now embed another post, the way a quote tweet does. +-- +-- The foreign key is self-referential and cascades. Deleting a post therefore +-- deletes every post that quotes it, and recursively the quotes of those, +-- which is what keeps a quote card from ever pointing at content that is gone. +-- The trade-off is deliberate: ON DELETE SET NULL would leave the quotes +-- standing with an empty card, and would need a second column to tell "never +-- quoted anything" apart from "quoted something that was deleted". +-- +-- Adding a nullable column is metadata-only in Postgres, so no table rewrite +-- happens here. The index is not concurrent because Prisma runs migrations +-- inside a transaction; if posts ever grows large enough for that to matter, +-- it should move to a separate manual step. + +-- AlterTable +ALTER TABLE "public"."posts" ADD COLUMN "quoted_post_id" TEXT; + +-- CreateIndex +CREATE INDEX "posts_quoted_post_id_idx" ON "public"."posts"("quoted_post_id"); + +-- AddForeignKey +ALTER TABLE "public"."posts" ADD CONSTRAINT "posts_quoted_post_id_fkey" FOREIGN KEY ("quoted_post_id") REFERENCES "public"."posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/post.prisma b/prisma/models/post.prisma index 580cbd78..27d04dd7 100644 --- a/prisma/models/post.prisma +++ b/prisma/models/post.prisma @@ -23,6 +23,13 @@ model Post { authorId String @map("author_id") author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + // A quote post embeds another post, the way a quote tweet does. The relation + // is self-referential and cascades: deleting a post deletes every post that + // quotes it, so a quote card can never point at content that is gone. + quotedPostId String? @map("quoted_post_id") + quotedPost Post? @relation("PostQuotes", fields: [quotedPostId], references: [id], onDelete: Cascade) + quotes Post[] @relation("PostQuotes") + tags Tag[] likes PostLike[] bookmarks PostBookmark[] @@ -38,6 +45,7 @@ model Post { @@index([createdAt]) @@index([type]) @@index([category]) + @@index([quotedPostId]) @@map("posts") } diff --git a/src/core/domain/entities/post.entity.ts b/src/core/domain/entities/post.entity.ts index 14fa268c..0ae65343 100644 --- a/src/core/domain/entities/post.entity.ts +++ b/src/core/domain/entities/post.entity.ts @@ -1,6 +1,7 @@ import type { PostType } from "@core/domain/enums/post-type.enum"; import type { PostProps } from "@core/domain/interfaces/post-props.interface"; import type { PostCategory } from "../enums/post-category-enum"; +import type { QuotedPostSnapshot } from "../interfaces/quoted-post.interface"; /** * Rich domain model for Post entity @@ -23,6 +24,7 @@ export class Post { * @param authorId - The unique identifier of the post's author. * @param mediaUrls - Optional. An array of media URLs associated with the post. Defaults to an empty array. * @param categories - Optional. An array of categories assigned to the post. Defaults to an empty array. + * @param quotedPostId - Optional. The id of the post this one quotes. * @returns A new Post instance with the specified properties. */ public static create( @@ -31,6 +33,7 @@ export class Post { authorId: string, mediaUrls: string[] = [], categories: PostCategory[] = [], + quotedPostId?: string, ): Post { return new Post({ content, @@ -39,6 +42,7 @@ export class Post { author: { id: authorId }, tags: [], categories, + quotedPostId, }); } @@ -155,6 +159,23 @@ export class Post { return this.props.categories ?? []; } + /** + * Get the id of the post this one quotes + * @returns The quoted post ID, or undefined when this is not a quote post + */ + get quotedPostId(): string | undefined { + return this.props.quotedPostId; + } + + /** + * Get the embedded snapshot of the quoted post + * @returns The quoted post, or undefined when this post quotes nothing or + * was loaded without its quote relation + */ + get quotedPost(): QuotedPostSnapshot | undefined { + return this.props.quotedPost; + } + /** * Check if the post has any media attached * @returns True if the post has one or more media items @@ -163,6 +184,14 @@ export class Post { return this.props.mediaUrls.length > 0; } + /** + * Check if this post quotes another post + * @returns True if the post was created as a quote of another post + */ + public isQuote(): boolean { + return this.props.quotedPostId !== undefined; + } + /** * Check if the given user is the author of this post * @param userId - The user ID to check diff --git a/src/core/domain/interfaces/post-props.interface.ts b/src/core/domain/interfaces/post-props.interface.ts index a6f31dc0..20f9ca5f 100644 --- a/src/core/domain/interfaces/post-props.interface.ts +++ b/src/core/domain/interfaces/post-props.interface.ts @@ -1,5 +1,6 @@ import type { PostType } from "@core/domain/enums"; import type { PostCategory } from "../enums/post-category-enum"; +import type { QuotedPostSnapshot } from "./quoted-post.interface"; /** * Props interface for Post entity @@ -69,4 +70,20 @@ export interface PostProps { /** Array of categories associated with the post */ categories: PostCategory[]; + + /** + * The post this one quotes, when it is a quote post. + * + * Only ever the id on a post built by `Post.create`; a post read back from + * the database carries `quotedPost` alongside it. + */ + quotedPostId?: string; + + /** + * The embedded snapshot of the quoted post. + * + * Present only when the post was loaded with its quote relation. A quote + * of a quote carries one level and no more - see {@link QuotedPostSnapshot}. + */ + quotedPost?: QuotedPostSnapshot; } diff --git a/src/core/domain/interfaces/quoted-post.interface.ts b/src/core/domain/interfaces/quoted-post.interface.ts new file mode 100644 index 00000000..e300b393 --- /dev/null +++ b/src/core/domain/interfaces/quoted-post.interface.ts @@ -0,0 +1,37 @@ +/** + * The snapshot of a post as it appears embedded inside a quote post. + * + * Deliberately narrower than {@link PostProps}: a quote card shows who wrote + * the quoted post, what it said and when, and nothing else. Keeping it a + * separate shape rather than nesting `PostProps` inside itself is what stops + * the embed from recursing - a quote of a quote carries only the post it + * quotes, never that post's own quote. + */ +export interface QuotedPostSnapshot { + /** The unique identifier of the quoted post */ + id: string; + + /** The content text of the quoted post */ + content: string; + + /** Media attached to the quoted post */ + mediaUrls: string[]; + + /** When the quoted post was created */ + createdAt: Date; + + /** The author of the quoted post */ + author: { + /** The unique identifier of the quoted post's author */ + id: string; + + /** Handle of the quoted post's author */ + username: string; + + /** Optional avatar URL of the author for display purposes */ + avatarUrl?: string; + + /** Optional display name of the author */ + fullName?: string; + }; +} diff --git a/src/core/use-cases/post/create-post/create-post-usecase.input.ts b/src/core/use-cases/post/create-post/create-post-usecase.input.ts index 831f9e74..eae0e9ef 100644 --- a/src/core/use-cases/post/create-post/create-post-usecase.input.ts +++ b/src/core/use-cases/post/create-post/create-post-usecase.input.ts @@ -33,4 +33,12 @@ export interface CreatePostInput { * Array of categories associated with the post for classification and discovery. */ categories?: PostCategory[]; + + /** + * Optional id of the post this one quotes. + * + * When set, the created post embeds that post as a quote card. Quoting a + * quote is allowed; only the read side stops at one level. + */ + quotedPostId?: string; } 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 684e33a7..58b66776 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 @@ -36,13 +36,20 @@ export class CreatePostUseCase { /** * Executes the post creation process. * - * @param input - Input containing post content, type, author ID, and media URLs + * @param input - Input containing post content, type, author ID, media URLs + * and the optional id of the post being quoted * @returns Promise - Resolves when post creation is complete * + * @throws NotFoundError - When quotedPostId names a post that does not exist + * * @remarks * 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. + * * 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. @@ -57,12 +64,21 @@ export class CreatePostUseCase { ); } } + + if (input.quotedPostId) { + const quoted = await this.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 rawPost = await this.postRepository.create(post); diff --git a/src/core/use-cases/post/get-posts/get-posts.usecase.ts b/src/core/use-cases/post/get-posts/get-posts.usecase.ts index f060d5af..3a5ef2b0 100644 --- a/src/core/use-cases/post/get-posts/get-posts.usecase.ts +++ b/src/core/use-cases/post/get-posts/get-posts.usecase.ts @@ -6,6 +6,7 @@ import type { GetPostsOutput } from "./get-posts-usecase.output"; import { Post } from "@core/domain/entities/post.entity"; import { UnauthorizedError } from "@core/errors"; import { PostType } from "@core/domain/enums"; +import type { QuotedPostSnapshot } from "@core/domain/interfaces/quoted-post.interface"; interface CachedPostData { id: string; @@ -20,6 +21,27 @@ interface CachedFeedData { total: number; } +/** + * Rebuilds the quoted post card that came back from the cache. + * + * `JSON.parse` leaves every date a string, and the caller revives only the + * top-level `createdAt` / `updatedAt`. Without this the same request would + * answer with a `Date` on a cache miss and a string for the next 60 seconds, + * so the serialised shape of the response would flip on its own. + * + * @param raw - The `quotedPost` value as it was parsed out of the cache + * @returns The card with a real `Date`, or undefined when nothing is quoted + */ +function hydrateQuotedPost(raw: unknown): QuotedPostSnapshot | undefined { + if (!raw) return undefined; + + const quoted = raw as Omit & { + createdAt: string; + }; + + return { ...quoted, createdAt: new Date(quoted.createdAt) }; +} + function shufflePosts(posts: Post[]): Post[] { const shuffled = [...posts]; for (let i = shuffled.length - 1; i > 0; i--) { @@ -69,6 +91,7 @@ export class GetPostsUseCase { id: p.id || (data.id as string), createdAt: new Date(data.createdAt as string), updatedAt: new Date(data.updatedAt as string), + quotedPost: hydrateQuotedPost(data.quotedPost), }); }); diff --git a/src/http/controllers/post.controller.ts b/src/http/controllers/post.controller.ts index 07d63a82..c0f7cb14 100644 --- a/src/http/controllers/post.controller.ts +++ b/src/http/controllers/post.controller.ts @@ -50,7 +50,8 @@ export class PostController { reply: FastifyReply, ): Promise { const authorId = request.user.id; - const { content, type, mediaUrls, categories } = request.body; + const { content, type, mediaUrls, categories, quotedPostId } = + request.body; const post = await this.createPostUseCase.execute({ authorId, @@ -58,6 +59,7 @@ export class PostController { type, mediaUrls, categories, + quotedPostId, }); const cdnUrl = this.normalizeCdnUrl( diff --git a/src/http/types/schemas/post/create-post.schema.ts b/src/http/types/schemas/post/create-post.schema.ts index 651336b8..6f137911 100644 --- a/src/http/types/schemas/post/create-post.schema.ts +++ b/src/http/types/schemas/post/create-post.schema.ts @@ -29,6 +29,12 @@ export const createPostBodySchema = Type.Object({ uniqueItems: true, }), ), + quotedPostId: Type.Optional( + Type.String({ + format: "uuid", + description: "The post this one quotes, rendered as a quote card", + }), + ), }); export type CreatePostBody = Static; diff --git a/src/http/types/schemas/post/get-post.schema.ts b/src/http/types/schemas/post/get-post.schema.ts index 4670da67..a5ede0a9 100644 --- a/src/http/types/schemas/post/get-post.schema.ts +++ b/src/http/types/schemas/post/get-post.schema.ts @@ -10,6 +10,22 @@ export const PostAuthorSchema = FBType.Object({ isMe: FBType.Optional(FBType.Boolean()), }); +/** + * The quoted post embedded in a quote post. + * + * One level only: a quote card carries no `quotedPost` of its own, and no + * counters or viewer-specific flags. + */ +export const QuotedPostSchema = FBType.Object({ + id: FBType.String({ format: "uuid" }), + content: FBType.String(), + mediaUrls: FBType.Array(FBType.String()), + createdAt: FBType.String(), + author: PostAuthorSchema, +}); + +export type QuotedPost = Static; + export const PostItemSchema = FBType.Object({ id: FBType.String({ format: "uuid" }), content: FBType.String(), @@ -23,6 +39,7 @@ export const PostItemSchema = FBType.Object({ author: PostAuthorSchema, tags: FBType.Array(FBType.Object({ name: FBType.String() })), categories: FBType.Array(FBType.Object({ name: FBType.String() })), + quotedPost: FBType.Union([QuotedPostSchema, FBType.Null()]), }); export type PostItem = Static; diff --git a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts index 2bf9feee..220e32b7 100644 --- a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts @@ -15,8 +15,42 @@ export type PostWithRelations = Prisma.PostGetPayload<{ tags: true; likes: true; bookmarks: true; + quotedPost: { + include: { + author: { + select: { + id: true; + username: true; + profile: { + select: { avatarUrl: true; fullName: true }; + }; + }; + }; + }; + }; }; }>; + +/** + * The quoted post as it is rendered inside a quote post. + * + * Deliberately leaner than {@link PostResponse}: no counters and no + * viewer-specific `isLiked` / `isBookmarked`, so embedding one costs no extra + * joins, and no nested `quotedPost`, so the embed never recurses. + */ +export interface QuotedPostResponse { + id: string; + content: string; + mediaUrls: string[]; + createdAt: Date; + author: { + id: string; + username: string; + avatarUrl: string; + fullName: string | null; + }; +} + export interface PostResponse { id: string; content: string; @@ -36,6 +70,7 @@ export interface PostResponse { isBookmarked: boolean; tags?: { name: string }[]; categories?: { name: string }[]; + quotedPost: QuotedPostResponse | null; } /** @@ -71,6 +106,25 @@ export class PostPrismaMapper { isLiked: dbPost.likes && dbPost.likes.length > 0, isBookmarked: dbPost.bookmarks && dbPost.bookmarks.length > 0, categories: (dbPost.category as PostCategory[]) || [], + quotedPostId: dbPost.quotedPostId ?? undefined, + quotedPost: dbPost.quotedPost + ? { + id: dbPost.quotedPost.id, + content: dbPost.quotedPost.content, + mediaUrls: dbPost.quotedPost.mediaUrls, + createdAt: dbPost.quotedPost.createdAt, + author: { + id: dbPost.quotedPost.authorId, + username: dbPost.quotedPost.author.username, + avatarUrl: + dbPost.quotedPost.author?.profile?.avatarUrl ?? + undefined, + fullName: + dbPost.quotedPost.author?.profile?.fullName ?? + undefined, + }, + } + : undefined, }); } @@ -86,6 +140,7 @@ export class PostPrismaMapper { mediaUrls: string[]; authorId: string; category: PostCategory[]; + quotedPostId: string | null; } { return { content: post.content, @@ -93,6 +148,7 @@ export class PostPrismaMapper { mediaUrls: post.mediaUrls, authorId: post.author.id, category: post.categories || [], + quotedPostId: post.quotedPostId ?? null, }; } @@ -132,21 +188,77 @@ export class PostPrismaMapper { author: { id: post.author.id, username, - avatarUrl: post.author.avatarUrl - ? post.author.avatarUrl.startsWith("http") - ? post.author.avatarUrl - : post.author.avatarUrl.includes("default_profile") - ? `${cdnUrl}/${post.author.avatarUrl}?v=1` - : `${cdnUrl}/${post.author.avatarUrl}` - : `${cdnUrl}/default-avatar.png`, + avatarUrl: this.resolveAvatarUrl(post.author.avatarUrl, cdnUrl), fullName: post.author.fullName ?? null, isMe: currentUserId ? post.author.id === currentUserId : false, }, tags: post.tags?.map((t) => ({ name: t })) || [], categories: post.categories?.map((c) => ({ name: c })) || [], + quotedPost: this.toQuotedPostResponse(post, cdnUrl), + }; + } + + /** + * Maps the quoted post carried by a quote post to its response card. + * + * @param post - The quoting Post domain entity. + * @param cdnUrl - Base URL for the CDN to resolve avatar links. + * @returns The embedded quote card, or null when the post quotes nothing. + * + * @remarks + * Unlike {@link toResponse} this never throws on a missing handle. The + * relation makes an author mandatory, so a card without one cannot exist, + * and a defensive empty handle is a better outcome here than failing the + * whole feed over one embedded post. + */ + private static toQuotedPostResponse( + post: Post, + cdnUrl: string, + ): QuotedPostResponse | null { + const quoted = post.quotedPost; + if (!quoted) return null; + + return { + id: quoted.id, + content: quoted.content, + mediaUrls: quoted.mediaUrls, + createdAt: quoted.createdAt, + author: { + id: quoted.author.id, + username: quoted.author.username ?? "", + avatarUrl: this.resolveAvatarUrl( + quoted.author.avatarUrl, + cdnUrl, + ), + fullName: quoted.author.fullName ?? null, + }, }; } + /** + * Resolves a stored avatar key onto the CDN. + * + * An absolute URL is left alone - OAuth avatars point at the provider - + * the seeded default gets a cache-busting suffix, and anything else is a + * key under the CDN base. Shared by the post's author and the quoted + * post's author so the two can never drift apart. + * + * @param avatarUrl - The stored avatar key or URL, if any. + * @param cdnUrl - Base URL for the CDN. + * @returns A URL the client can render directly. + */ + private static resolveAvatarUrl( + avatarUrl: string | undefined, + cdnUrl: string, + ): string { + if (!avatarUrl) return `${cdnUrl}/default-avatar.png`; + if (avatarUrl.startsWith("http")) return avatarUrl; + if (avatarUrl.includes("default_profile")) { + return `${cdnUrl}/${avatarUrl}?v=1`; + } + return `${cdnUrl}/${avatarUrl}`; + } + /** * Maps an array of Domain entities to safe public response objects. * diff --git a/src/infrastructure/persistence/repositories/prisma-post.repository.ts b/src/infrastructure/persistence/repositories/prisma-post.repository.ts index 235dec11..55779730 100644 --- a/src/infrastructure/persistence/repositories/prisma-post.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-post.repository.ts @@ -11,6 +11,31 @@ import type { PostType } from "@core/domain/enums/post-type.enum"; import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; import type { Prisma } from "@generated/prisma/client"; +/** + * The author fields every post read selects. + * + * `as const` is load-bearing: the include clauses below are inferred by Prisma + * at each call site, and a widened `boolean` here would cost that inference. + */ +const POST_AUTHOR_SELECT = { + select: { + id: true, + username: true, + profile: { select: { avatarUrl: true, fullName: true } }, + }, +} as const; + +/** + * The quoted post embedded in a quote post. + * + * One level deep and without its own quote: a quote card shows the post being + * quoted, never the chain behind it. Counters and the viewer's like/bookmark + * state are left out too, so embedding one costs no extra joins. + */ +const QUOTED_POST_INCLUDE = { + include: { author: POST_AUTHOR_SELECT }, +} as const; + /** * Prisma implementation of the Post repository * @@ -56,18 +81,11 @@ export class PrismaPostRepository implements IPostRepository { }, }, include: { - author: { - select: { - id: true, - username: true, - profile: { - select: { avatarUrl: true, fullName: true }, - }, - }, - }, + author: POST_AUTHOR_SELECT, tags: true, likes: false, bookmarks: false, + quotedPost: QUOTED_POST_INCLUDE, }, }); @@ -121,15 +139,7 @@ export class PrismaPostRepository implements IPostRepository { take: limit, orderBy, include: { - author: { - select: { - id: true, - username: true, - profile: { - select: { avatarUrl: true, fullName: true }, - }, - }, - }, + author: POST_AUTHOR_SELECT, tags: true, likes: currentUserId ? { where: { userId: currentUserId } } @@ -137,6 +147,7 @@ export class PrismaPostRepository implements IPostRepository { bookmarks: currentUserId ? { where: { userId: currentUserId } } : false, + quotedPost: QUOTED_POST_INCLUDE, }, }), ]); @@ -158,15 +169,7 @@ export class PrismaPostRepository implements IPostRepository { const raw = await this.prisma.post.findUnique({ where: { id }, include: { - author: { - select: { - id: true, - username: true, - profile: { - select: { avatarUrl: true, fullName: true }, - }, - }, - }, + author: POST_AUTHOR_SELECT, tags: true, likes: currentUserId ? { where: { userId: currentUserId } } @@ -174,6 +177,7 @@ export class PrismaPostRepository implements IPostRepository { bookmarks: currentUserId ? { where: { userId: currentUserId } } : false, + quotedPost: QUOTED_POST_INCLUDE, }, }); @@ -253,15 +257,7 @@ export class PrismaPostRepository implements IPostRepository { skip, take: limit, include: { - author: { - select: { - id: true, - username: true, - profile: { - select: { avatarUrl: true, fullName: true }, - }, - }, - }, + author: POST_AUTHOR_SELECT, tags: true, likes: currentUserId ? { where: { userId: currentUserId } } @@ -269,6 +265,7 @@ export class PrismaPostRepository implements IPostRepository { bookmarks: currentUserId ? { where: { userId: currentUserId } } : false, + quotedPost: QUOTED_POST_INCLUDE, }, }), ]); diff --git a/tests/e2e/post/create.test.ts b/tests/e2e/post/create.test.ts index 534d133f..2d667084 100644 --- a/tests/e2e/post/create.test.ts +++ b/tests/e2e/post/create.test.ts @@ -125,6 +125,128 @@ describe("POST /posts - Create Post", () => { expect(body.title).toBe("UnauthorizedError"); }); + describe("Quote posts", () => { + let originalPostId = ""; + + beforeAll(async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "The post everyone quotes" }, + }); + originalPostId = parseBody<{ data: { id: string } }>(response).data + .id; + }); + + it("should return 201 with the quoted post embedded as a card", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "Quoting the original", + quotedPostId: originalPostId, + }, + }); + const body = parseBody<{ + data: { + content: string; + quotedPost: { + id: string; + content: string; + mediaUrls: string[]; + createdAt: string; + author: { username: string; avatarUrl: string }; + } | null; + }; + }>(response); + + expect(response.statusCode).toBe(201); + expect(body.data.content).toBe("Quoting the original"); + expect(body.data.quotedPost).not.toBeNull(); + expect(body.data.quotedPost?.id).toBe(originalPostId); + expect(body.data.quotedPost?.content).toBe( + "The post everyone quotes", + ); + expect(body.data.quotedPost?.author.username).toBe(user.username); + // A quote card carries no counters and no second level. + expect(body.data.quotedPost).not.toHaveProperty("likeCount"); + expect(body.data.quotedPost).not.toHaveProperty("quotedPost"); + }); + + it("should return quotedPost as null for a post that quotes nothing", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "A post that quotes nothing" }, + }); + const body = parseBody<{ data: { quotedPost: unknown } }>(response); + + expect(response.statusCode).toBe(201); + expect(body.data.quotedPost).toBeNull(); + }); + + it("should return 404 when the quoted post does not exist", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "Quoting a ghost", + quotedPostId: "00000000-0000-0000-0000-000000000000", + }, + }); + const body = parseBody<{ title: string }>(response); + + expect(response.statusCode).toBe(404); + expect(body.title).toBe("NotFoundError"); + }); + + it("should return 400 when quotedPostId is not a uuid", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "Quoting nonsense", + quotedPostId: "not-a-uuid", + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should delete the quote when the quoted post is deleted", async () => { + const originalRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "Doomed original" }, + }); + const doomedId = parseBody<{ data: { id: string } }>(originalRes) + .data.id; + + const quoteRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "Quoting a doomed post", + quotedPostId: doomedId, + }, + }); + const quoteId = parseBody<{ data: { id: string } }>(quoteRes).data + .id; + + const deleteRes = await authRequest(accessToken, { + method: "DELETE", + url: `/posts/${doomedId}`, + }); + expect(deleteRes.statusCode).toBe(204); + + const readBack = await request({ + method: "GET", + url: `/posts/${quoteId}`, + }); + expect(readBack.statusCode).toBe(404); + }); + }); + describe("Bot user post type restrictions", () => { let botAccessToken = ""; diff --git a/tests/e2e/post/get-feed.test.ts b/tests/e2e/post/get-feed.test.ts index bee46c28..2b542640 100644 --- a/tests/e2e/post/get-feed.test.ts +++ b/tests/e2e/post/get-feed.test.ts @@ -181,4 +181,51 @@ describe("GET /posts - Get Post Feed", () => { expect(response.statusCode).toBe(200); expect(body.data.length).toBeGreaterThanOrEqual(1); }); + + it("should carry the quote card through the feed, cached or not", async () => { + const originalRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "E2E feed quote target" }, + }); + const originalId = parseBody<{ data: { id: string } }>(originalRes).data + .id; + + const quoteRes = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "E2E feed quote", + quotedPostId: originalId, + }, + }); + const quoteId = parseBody<{ data: { id: string } }>(quoteRes).data.id; + + type FeedBody = { + data: { + id: string; + quotedPost: { id: string; createdAt: string } | null; + }[]; + }; + + const findQuote = (body: FeedBody): FeedBody["data"][number] => { + const found = body.data.find((post) => post.id === quoteId); + expect(found).toBeDefined(); + return found!; + }; + + const first = await request({ method: "GET", url: "/posts?limit=50" }); + expect(first.statusCode).toBe(200); + const fromDb = findQuote(parseBody(first)); + + expect(fromDb.quotedPost?.id).toBe(originalId); + + // The second read is served from the 60-second feed cache. The dates in + // it are revived by hand, so the shape must not drift between the two. + const second = await request({ method: "GET", url: "/posts?limit=50" }); + expect(second.statusCode).toBe(200); + const fromCache = findQuote(parseBody(second)); + + expect(fromCache.quotedPost).toEqual(fromDb.quotedPost); + }); }); diff --git a/tests/integration/persistence/repositories/prisma-post.repository.test.ts b/tests/integration/persistence/repositories/prisma-post.repository.test.ts index 7f949959..7c9a4ca1 100644 --- a/tests/integration/persistence/repositories/prisma-post.repository.test.ts +++ b/tests/integration/persistence/repositories/prisma-post.repository.test.ts @@ -170,4 +170,99 @@ describe("PrismaPostRepository (integration)", () => { expect(found).toBeNull(); }); }); + + describe("quote posts", () => { + it("should persist the quoted post id and read the card back", async () => { + const original = await postRepo.create( + Post.create("The original", PostType.COMMUNITY, testUserId), + ); + + const quote = await postRepo.create( + Post.create( + "Quoting the original", + PostType.COMMUNITY, + testUserId, + [], + [], + original.id, + ), + ); + + expect(quote.quotedPostId).toBe(original.id); + expect(quote.quotedPost?.id).toBe(original.id); + expect(quote.quotedPost?.content).toBe("The original"); + expect(quote.quotedPost?.author.username).toBe( + "postauthor_postrepo", + ); + + const readBack = await postRepo.findById(quote.id); + expect(readBack?.quotedPost?.content).toBe("The original"); + }); + + it("should include only one level of quote", async () => { + // A quote card shows the post being quoted, never the chain behind + // it - otherwise a long chain would drag its whole history into + // every feed row. + const original = await postRepo.create( + Post.create("Level 0", PostType.COMMUNITY, testUserId), + ); + const firstQuote = await postRepo.create( + Post.create( + "Level 1", + PostType.COMMUNITY, + testUserId, + [], + [], + original.id, + ), + ); + const secondQuote = await postRepo.create( + Post.create( + "Level 2", + PostType.COMMUNITY, + testUserId, + [], + [], + firstQuote.id, + ), + ); + + const readBack = await postRepo.findById(secondQuote.id); + + expect(readBack?.quotedPost?.content).toBe("Level 1"); + expect(readBack?.quotedPost).not.toHaveProperty("quotedPost"); + }); + + it("should leave quotedPost unset on a post that quotes nothing", async () => { + const created = await postRepo.create( + Post.create("No quote here", PostType.COMMUNITY, testUserId), + ); + + const readBack = await postRepo.findById(created.id); + + expect(readBack?.quotedPostId).toBeUndefined(); + expect(readBack?.quotedPost).toBeUndefined(); + expect(readBack?.isQuote()).toBe(false); + }); + + it("should cascade the delete of an original onto its quotes", async () => { + const original = await postRepo.create( + Post.create("Doomed original", PostType.COMMUNITY, testUserId), + ); + const quote = await postRepo.create( + Post.create( + "Quoting a doomed post", + PostType.COMMUNITY, + testUserId, + [], + [], + original.id, + ), + ); + + await postRepo.delete(original.id); + + expect(await postRepo.findById(quote.id)).toBeNull(); + }); + }); }); 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 4fad4036..0f1954f5 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 @@ -12,7 +12,7 @@ import { buildUser, buildPost } from "../../../helpers/mock-factories"; describe("CreatePostUseCase", () => { let useCase: CreatePostUseCase; - let postRepository: Pick; + let postRepository: Pick; let userRepository: Pick; let cacheService: Pick; let notifyNewPostUseCase: Pick; @@ -21,6 +21,7 @@ describe("CreatePostUseCase", () => { beforeEach(() => { postRepository = { create: vi.fn().mockResolvedValue(buildPost()), + findById: vi.fn().mockResolvedValue(buildPost()), }; userRepository = { findById: vi.fn(), @@ -154,4 +155,70 @@ describe("CreatePostUseCase", () => { }); }); }); + + describe("quote posts", () => { + it("should carry quotedPostId onto the created post", 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", + }); + + const created = vi.mocked(postRepository.create).mock.calls[0][0]; + expect(created.quotedPostId).toBe("post-0"); + expect(created.isQuote()).toBe(true); + }); + + it("should throw NotFoundError when the quoted post is gone", async () => { + vi.mocked(postRepository.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ + content: "I agree with this", + type: PostType.COMMUNITY, + authorId: "user-1", + quotedPostId: "missing-post", + }), + ).rejects.toThrow(NotFoundError); + + expect(postRepository.create).not.toHaveBeenCalled(); + }); + + it("should not look up anything when nothing is quoted", async () => { + await useCase.execute({ + content: "Just a post", + type: PostType.COMMUNITY, + authorId: "user-1", + }); + + expect(postRepository.findById).not.toHaveBeenCalled(); + expect( + vi.mocked(postRepository.create).mock.calls[0][0].isQuote(), + ).toBe(false); + }); + + 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. + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ id: "post-1", quotedPostId: "post-0" }), + ); + + await useCase.execute({ + content: "and another thing", + type: PostType.COMMUNITY, + authorId: "user-2", + quotedPostId: "post-1", + }); + + expect( + vi.mocked(postRepository.create).mock.calls[0][0].quotedPostId, + ).toBe("post-1"); + }); + }); }); diff --git a/tests/unit/core/use-cases/post/get-posts.usecase.test.ts b/tests/unit/core/use-cases/post/get-posts.usecase.test.ts index ecf6cfd7..02a03168 100644 --- a/tests/unit/core/use-cases/post/get-posts.usecase.test.ts +++ b/tests/unit/core/use-cases/post/get-posts.usecase.test.ts @@ -128,6 +128,43 @@ describe("GetPostsUseCase", () => { expect(postRepository.findAll).not.toHaveBeenCalled(); }); + it("should revive the quoted post's date on a cache hit", async () => { + // Caching a Post serialises its private props bag, and every date in + // it comes back a string. Without the nested revival the same request + // would answer with a Date on a miss and a string for the 60 seconds + // after it. + const post = buildPost({ + quotedPostId: "post-0", + quotedPost: { + id: "post-0", + content: "The quoted post", + mediaUrls: [], + createdAt: new Date("2024-01-01T00:00:00Z"), + author: { id: "user-9", username: "quoted-author" }, + }, + }); + vi.mocked(cacheService.get).mockResolvedValue( + JSON.stringify({ posts: [post], total: 1 }), + ); + + const result = await useCase.execute({ page: 1, limit: 10 }); + + expect(result.posts[0].quotedPost?.createdAt).toBeInstanceOf(Date); + expect(result.posts[0].quotedPost?.content).toBe("The quoted post"); + expect(result.posts[0].quotedPostId).toBe("post-0"); + }); + + it("should leave a cached plain post without a quote", async () => { + vi.mocked(cacheService.get).mockResolvedValue( + JSON.stringify({ posts: [buildPost()], total: 1 }), + ); + + const result = await useCase.execute({ page: 1, limit: 10 }); + + expect(result.posts[0].quotedPost).toBeUndefined(); + expect(result.posts[0].isQuote()).toBe(false); + }); + describe("ordering", () => { const ids = ["p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8"]; diff --git a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts index 65f32504..3b4aadb2 100644 --- a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts +++ b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts @@ -305,4 +305,101 @@ describe("PostPrismaMapper", () => { ); }); }); + + describe("quote posts", () => { + const quotedRelation = { + id: "post-0", + content: "The quoted post", + mediaUrls: ["uploads/quoted.png"], + createdAt: now, + authorId: "user-9", + author: { + id: "user-9", + username: "quoted-author", + profile: { + avatarUrl: "uploads/quoted-avatar.jpg", + fullName: "Quoted Author", + }, + }, + }; + + it("should map the quoted relation onto the entity", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ + quotedPostId: "post-0", + quotedPost: quotedRelation, + } as never), + ); + + expect(post.isQuote()).toBe(true); + expect(post.quotedPostId).toBe("post-0"); + expect(post.quotedPost?.content).toBe("The quoted post"); + expect(post.quotedPost?.author.username).toBe("quoted-author"); + }); + + it("should leave the entity without a quote when nothing is quoted", () => { + const post = PostPrismaMapper.toDomainPost(makeDbPost()); + + expect(post.isQuote()).toBe(false); + expect(post.quotedPost).toBeUndefined(); + }); + + it("should carry quotedPostId through to the Prisma payload", () => { + const quote = PostPrismaMapper.toDomainPost( + makeDbPost({ + quotedPostId: "post-0", + quotedPost: quotedRelation, + } as never), + ); + const plain = PostPrismaMapper.toDomainPost(makeDbPost()); + + expect(PostPrismaMapper.toPrismaPost(quote).quotedPostId).toBe( + "post-0", + ); + expect(PostPrismaMapper.toPrismaPost(plain).quotedPostId).toBeNull(); + }); + + it("should render the quote card with a CDN-resolved avatar", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ + quotedPostId: "post-0", + quotedPost: quotedRelation, + } as never), + ); + const result = PostPrismaMapper.toResponse(post, CDN); + + expect(result.quotedPost).toEqual({ + id: "post-0", + content: "The quoted post", + mediaUrls: ["uploads/quoted.png"], + createdAt: now, + author: { + id: "user-9", + username: "quoted-author", + avatarUrl: `${CDN}/uploads/quoted-avatar.jpg`, + fullName: "Quoted Author", + }, + }); + }); + + it("should send quotedPost as null for a post that quotes nothing", () => { + const post = PostPrismaMapper.toDomainPost(makeDbPost()); + + expect(PostPrismaMapper.toResponse(post, CDN).quotedPost).toBeNull(); + }); + + 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. + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ + quotedPostId: "post-0", + quotedPost: quotedRelation, + } as never), + ); + const result = PostPrismaMapper.toResponse(post, CDN); + + expect(result.quotedPost).not.toHaveProperty("quotedPost"); + }); + }); });