Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/entities/comment/api/comment.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,17 @@ function createFakeAdapter() {
id: 10,
user_id: "user-1",
content: "post",
description: "post summary",
code: null,
language: null,
authoring_mode: "hand_written",
created_at: "2026-03-12T00:00:00.000Z",
updated_at: null,
deleted_at: null,
comment_count: 0,
like_count: 0,
bookmark_count: 0,
view_count: 0,
is_review_enabled: true,
},
],
);
Expand Down
63 changes: 60 additions & 3 deletions src/entities/post/api/post.action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
getCurrentUserAuthMock,
getPostByIdMock,
getReviewCommentsCountMock,
hasUserPostedOnLocalDayMock,
revalidatePathMock,
updatePostMock,
} = vi.hoisted(() => ({
Expand All @@ -14,6 +15,7 @@ const {
createPostMock: vi.fn(),
getPostByIdMock: vi.fn(),
getReviewCommentsCountMock: vi.fn(),
hasUserPostedOnLocalDayMock: vi.fn(),
updatePostMock: vi.fn(),
deletePostMock: vi.fn(),
}));
Expand All @@ -30,6 +32,7 @@ vi.mock("@/entities/post/api/post.service", () => ({
createPost: createPostMock,
getPostById: getPostByIdMock,
getReviewCommentsCount: getReviewCommentsCountMock,
hasUserPostedOnLocalDay: hasUserPostedOnLocalDayMock,
updatePost: updatePostMock,
deletePost: deletePostMock,
}));
Expand All @@ -39,6 +42,10 @@ import { createPostAction, updatePostAction } from "./post.action";
describe("post.action", () => {
beforeEach(() => {
vi.clearAllMocks();
hasUserPostedOnLocalDayMock.mockResolvedValue({
data: false,
error: null,
});
});

it("createPostAction은 서버 세션 유저를 author로 강제한다", async () => {
Expand All @@ -64,13 +71,27 @@ describe("post.action", () => {
bio: "",
},
content: "content",
description: "description",
code: "const a = 1;",
language: "typescript",
authoring_mode: "hand_written",
tags: ["ts"],
is_review_enabled: true,
localDayContext: {
dayKey: "2026-03-25",
dayStartAt: "2026-03-24T15:00:00.000Z",
dayEndAt: "2026-03-25T14:59:59.999Z",
timezoneOffsetMinutes: -540,
},
});

expect(result.error).toBeNull();
expect(hasUserPostedOnLocalDayMock).toHaveBeenCalledWith({
dayKey: "2026-03-25",
dayStartAt: "2026-03-24T15:00:00.000Z",
dayEndAt: "2026-03-25T14:59:59.999Z",
timezoneOffsetMinutes: -540,
userId: "server-user",
});
expect(createPostMock).toHaveBeenCalledWith(
expect.objectContaining({
author: user,
Expand All @@ -79,6 +100,42 @@ describe("post.action", () => {
expect(revalidatePathMock).toHaveBeenCalledWith("/home");
});

it("createPostAction은 오늘 이미 작성한 경우 생성을 차단한다", async () => {
getCurrentUserAuthMock.mockResolvedValue({
id: "server-user",
username: "server_user",
});
hasUserPostedOnLocalDayMock.mockResolvedValue({
data: true,
error: null,
});

const result = await createPostAction({
author: {
id: "client-user",
username: "client_user",
nickname: "Client User",
avatar: "",
bio: "",
},
content: "content",
description: "description",
code: null,
language: null,
authoring_mode: "hand_written",
tags: [],
localDayContext: {
dayKey: "2026-03-25",
dayStartAt: "2026-03-24T15:00:00.000Z",
dayEndAt: "2026-03-25T14:59:59.999Z",
timezoneOffsetMinutes: -540,
},
});

expect(result).toEqual({ error: "오늘은 이미 글을 작성했습니다." });
expect(createPostMock).not.toHaveBeenCalled();
});

it("updatePostAction은 타인 포스트 수정을 차단한다", async () => {
getCurrentUserAuthMock.mockResolvedValue({
id: "me",
Expand All @@ -99,7 +156,7 @@ describe("post.action", () => {
expect(updatePostMock).not.toHaveBeenCalled();
});

it("리뷰 댓글이 존재하면 코드 수정을 차단한다", async () => {
it("인라인 코멘트가 존재하면 코드 수정을 차단한다", async () => {
getCurrentUserAuthMock.mockResolvedValue({
id: "me",
username: "me",
Expand All @@ -120,7 +177,7 @@ describe("post.action", () => {
const result = await updatePostAction(1, { code: "changed" });

expect(result).toEqual({
error: "코드 리뷰가 존재하는 포스트는 코드를 수정할 수 없습니다.",
error: "인라인 코멘트가 존재하는 포스트는 코드를 수정할 수 없습니다.",
});
expect(updatePostMock).not.toHaveBeenCalled();
});
Expand Down
30 changes: 27 additions & 3 deletions src/entities/post/api/post.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,43 @@ import {
deletePost,
getPostById,
getReviewCommentsCount,
hasUserPostedOnLocalDay,
updatePost,
} from "@/entities/post/api/post.service";
import { type LocalDayContext } from "@/shared/lib/date";
import { getCurrentUserAuth } from "@/shared/lib/supabase/current-user";

async function createPostAction(data: CreatePostDTO) {
type CreatePostActionInput = CreatePostDTO & {
localDayContext: LocalDayContext;
};

async function createPostAction(data: CreatePostActionInput) {
const user = await getCurrentUserAuth();

if (!user) {
return { error: "로그인이 필요합니다." };
}

const { localDayContext, ...postData } = data;
const { data: hasPostedToday, error: hasPostedError } =
await hasUserPostedOnLocalDay({
...localDayContext,
userId: user.id,
});

if (hasPostedError) {
console.error(hasPostedError);
return {
error: hasPostedError.message || "오늘 작성 여부 확인에 실패했습니다.",
};
}

if (hasPostedToday) {
return { error: "오늘은 이미 글을 작성했습니다." };
}

// 데이터의 author는 클라이언트에서 오므로, 서버의 신뢰할 수 있는 user 정보로 덮어씌웁니다.
const secureData = { ...data, author: user };
const secureData = { ...postData, author: user };

const { data: newPost, error } = await createPost(secureData);

Expand Down Expand Up @@ -61,7 +85,7 @@ async function updatePostAction(id: number, data: Partial<CreatePostDTO>) {

if (count && count > 0) {
return {
error: "코드 리뷰가 존재하는 포스트는 코드를 수정할 수 없습니다.",
error: "인라인 코멘트가 존재하는 포스트는 코드를 수정할 수 없습니다.",
};
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/entities/post/api/post.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@ import { Post } from "@/shared/types/types";
*/
export type CreatePostDTO = Pick<
Post,
"author" | "content" | "code" | "language" | "tags" | "is_review_enabled"
| "author"
| "content"
| "description"
| "code"
| "language"
| "authoring_mode"
| "tags"
>;
10 changes: 5 additions & 5 deletions src/entities/post/api/post.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ describe("post.service", () => {
});

await getPosts({
isReviewEnabled: true,
authorId: "author-1",
likedByUserId: "liked-user",
bookmarkedByUserId: "bookmark-user",
Expand All @@ -59,11 +58,10 @@ describe("post.service", () => {
{ column: "post_likes.user_id", value: "liked-user" },
{ column: "bookmarks.user_id", value: "bookmark-user" },
{ column: "filter_tags.tags.name", value: "typescript" },
{ column: "is_review_enabled", value: true },
{ column: "user_id", value: "author-1" },
]),
range: { from: 0, to: 2 },
or: "content.ilike.%a b\\_\\% test%,code.ilike.%a b\\_\\% test%",
or: "description.ilike.%a b\\_\\% test%,content.ilike.%a b\\_\\% test%,code.ilike.%a b\\_\\% test%",
}),
);
});
Expand Down Expand Up @@ -100,19 +98,21 @@ describe("post.service", () => {

const result = await updatePost(99, {
content: "updated",
description: "summary",
code: "const x = 1;",
language: "typescript",
is_review_enabled: true,
authoring_mode: "ai_assisted",
tags: ["ts", "vitest"],
});

expect(adapter.rpc).toHaveBeenCalledWith("update_post_with_tags", {
p_post_id: 99,
post_data: {
content: "updated",
description: "summary",
code: "const x = 1;",
language: "typescript",
is_review_enabled: true,
authoring_mode: "ai_assisted",
},
tags: ["ts", "vitest"],
});
Expand Down
49 changes: 26 additions & 23 deletions src/entities/post/api/post.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
isValidLocalDayContext,
type LocalDayContext,
} from "@/shared/lib/date";
import { Database, Tables } from "@/shared/types/database.types";
import { Tables } from "@/shared/types/database.types";
import { Post } from "@/shared/types/types";

import { CreatePostDTO } from "./post.interface";
Expand All @@ -24,6 +24,15 @@ type LocalDayListQueryOptions = LocalDayContext & {
limit?: number;
};

type PostMutationPayload = {
content: string;
description: string;
code: string | null;
language: string | null;
user_id?: string;
authoring_mode: Post["authoring_mode"];
};

function sanitizeKeywordForOrFilter(rawKeyword: string): string {
return rawKeyword.replace(/[(),]/g, " ").replace(/\s+/g, " ").trim();
}
Expand Down Expand Up @@ -87,14 +96,14 @@ export async function createPost(
data: CreatePostDTO,
): Promise<{ data: Post | null; error: Error | null }> {
const db = getDatabaseAdapter();
const postData: Database["public"]["Functions"]["create_post_with_tags"]["Args"]["post_data"] =
{
content: data.content,
code: data.code,
language: data.language,
user_id: data.author.id,
is_review_enabled: data.is_review_enabled,
};
const postData: PostMutationPayload = {
content: data.content,
description: data.description,
code: data.code,
language: data.language,
user_id: data.author.id,
authoring_mode: data.authoring_mode,
};

const { data: insertedPost, error } = await db.rpc<Tables<"posts">>(
"create_post_with_tags",
Expand All @@ -118,7 +127,6 @@ export async function createPost(
}

export async function getPosts({
isReviewEnabled = false,
authorId,
likedByUserId,
bookmarkedByUserId,
Expand All @@ -127,7 +135,6 @@ export async function getPosts({
offset,
limit,
}: {
isReviewEnabled?: boolean;
authorId?: string;
likedByUserId?: string;
bookmarkedByUserId?: string;
Expand Down Expand Up @@ -167,10 +174,6 @@ export async function getPosts({
filters.push({ column: "filter_tags.tags.name", value: tag });
}

if (isReviewEnabled) {
filters.push({ column: "is_review_enabled", value: true });
}

if (authorId) {
filters.push({ column: "user_id", value: authorId });
}
Expand All @@ -187,7 +190,7 @@ export async function getPosts({
select: selectString,
filters,
or: escapedKeyword
? `content.ilike.%${escapedKeyword}%,code.ilike.%${escapedKeyword}%`
? `description.ilike.%${escapedKeyword}%,content.ilike.%${escapedKeyword}%,code.ilike.%${escapedKeyword}%`
: undefined,
orderBy: { column: "created_at", ascending: false },
range:
Expand Down Expand Up @@ -435,13 +438,13 @@ export async function updatePost(
): Promise<{ data: Post | null; error: Error | null }> {
const db = getDatabaseAdapter();
const { tags, ...postFields } = data;
const postData: Database["public"]["Functions"]["update_post_with_tags"]["Args"]["post_data"] =
{
content: postFields.content,
code: postFields.code,
language: postFields.language,
is_review_enabled: postFields.is_review_enabled,
};
const postData: Partial<PostMutationPayload> = {
content: postFields.content,
description: postFields.description,
code: postFields.code,
language: postFields.language,
authoring_mode: postFields.authoring_mode,
};

const { error } = await db.rpc<unknown>("update_post_with_tags", {
p_post_id: id,
Expand Down
2 changes: 1 addition & 1 deletion src/features/post-interaction/model/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { type PostFormData,postSchema } from "./post.schema";
export { type PostFormData, postSchema } from "./post.schema";
6 changes: 5 additions & 1 deletion src/features/post-interaction/model/post.schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { z } from "zod";

export const postSchema = z.object({
description: z
.string()
.trim()
.min(1, "짧은 설명을 입력해주세요.")
.max(200, "짧은 설명은 200자 이하여야 합니다."),
content: z
.string()
.trim()
.min(1, "게시글 내용을 입력해주세요.")
.max(5000, "게시글은 5000자 이하여야 합니다."),
code: z.string().nullable(),
language: z.string().nullable(),
isReviewEnabled: z.boolean(),
});

export type PostFormData = z.infer<typeof postSchema>;
Loading
Loading