From 4c108e5cf55214ccf06c850eb7fba06fcc36f352 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Mon, 20 Apr 2026 20:42:27 +0800 Subject: [PATCH] test(comments): add compatibility fallback and service/api coverage Add a temporary legacy fallback so guest payloads missing both guestEmail and guestIsAnonymous are treated as anonymous during rollout. Add service-level and API body parsing tests for guest email rules, logged-in ignore behavior, and public/unlisted passthrough. Made-with: Cursor --- server/api/public/comments-create-body.test.ts | 47 ++++++++++++++++++++ server/api/public/comments-create-body.ts | 51 ++++++++++++++++++++++ .../[publicSlug]/posts/[postSlug]/comments.post.ts | 24 +++------- .../[publicSlug]/[shareToken]/comments.post.ts | 24 +++------- server/service/post-comments/guest-fields.test.ts | 42 ++++++++++++++++++ server/service/post-comments/guest-fields.ts | 23 ++++++++++ server/service/post-comments/index.ts | 11 +++-- 7 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 server/api/public/comments-create-body.test.ts create mode 100644 server/api/public/comments-create-body.ts create mode 100644 server/service/post-comments/guest-fields.test.ts create mode 100644 server/service/post-comments/guest-fields.ts diff --git a/server/api/public/comments-create-body.test.ts b/server/api/public/comments-create-body.test.ts new file mode 100644 index 0000000..1051403 --- /dev/null +++ b/server/api/public/comments-create-body.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { parsePublicPostCreateCommentBody, parseUnlistedCreateCommentBody } from "./comments-create-body"; + +globalThis.createError ??= ((input: { statusCode: number; statusMessage: string }) => { + const error = new Error(input.statusMessage) as Error & { statusCode: number; statusMessage: string }; + error.statusCode = input.statusCode; + error.statusMessage = input.statusMessage; + return error; +}) as any; + +describe("parsePublicPostCreateCommentBody", () => { + test("passes through guestEmail and guestIsAnonymous for public endpoint", () => { + const parsed = parsePublicPostCreateCommentBody({ + parentId: 11, + guestDisplayName: "访客A", + guestEmail: "guest@example.com", + guestIsAnonymous: true, + body: "hello", + }); + expect(parsed).toEqual({ + parentId: 11, + guestDisplayName: "访客A", + guestEmail: "guest@example.com", + guestIsAnonymous: true, + body: "hello", + }); + }); +}); + +describe("parseUnlistedCreateCommentBody", () => { + test("passes through guestEmail and guestIsAnonymous for unlisted endpoint", () => { + const parsed = parseUnlistedCreateCommentBody({ + parentId: null, + guestDisplayName: "访客B", + guestEmail: "anon@example.com", + guestIsAnonymous: false, + body: "world", + }); + expect(parsed).toEqual({ + parentId: null, + guestDisplayName: "访客B", + guestEmail: "anon@example.com", + guestIsAnonymous: false, + body: "world", + }); + }); +}); diff --git a/server/api/public/comments-create-body.ts b/server/api/public/comments-create-body.ts new file mode 100644 index 0000000..1b467b8 --- /dev/null +++ b/server/api/public/comments-create-body.ts @@ -0,0 +1,51 @@ +type CreateCommentRequestBody = { + parentId?: number | null; + guestDisplayName?: string; + guestEmail?: string; + guestIsAnonymous?: boolean; + body: unknown; +}; + +export type ParsedCreateCommentInput = { + parentId: number | null; + guestDisplayName?: string; + guestEmail?: string; + guestIsAnonymous: boolean; + body: string; +}; + +export function parsePublicPostCreateCommentBody(body: CreateCommentRequestBody): ParsedCreateCommentInput { + if (typeof body.body !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + return { + parentId: parseParentId(body.parentId), + guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, + guestEmail: typeof body.guestEmail === "string" ? body.guestEmail : undefined, + guestIsAnonymous: body.guestIsAnonymous === true, + body: body.body, + }; +} + +export function parseUnlistedCreateCommentBody(body: CreateCommentRequestBody): ParsedCreateCommentInput { + if (typeof body.body !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + return { + parentId: parseParentId(body.parentId), + guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, + guestEmail: typeof body.guestEmail === "string" ? body.guestEmail : undefined, + guestIsAnonymous: body.guestIsAnonymous === true, + body: body.body, + }; +} + +function parseParentId(rawParentId: unknown): number | null { + if (rawParentId === undefined || rawParentId === null) { + return null; + } + if (typeof rawParentId === "number" && Number.isFinite(rawParentId)) { + return rawParentId; + } + throw createError({ statusCode: 400, statusMessage: "无效请求" }); +} diff --git a/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts index 7e28e23..a95fa4e 100644 --- a/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts +++ b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts @@ -2,6 +2,7 @@ import { getRequestIP } from "h3"; import { getPublicPostCommentContext } from "#server/service/posts"; import { createComment } from "#server/service/post-comments"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; +import { parsePublicPostCreateCommentBody } from "#server/api/public/comments-create-body"; export default defineEventHandler(async (event) => { const publicSlug = event.context.params?.publicSlug; @@ -31,28 +32,17 @@ export default defineEventHandler(async (event) => { body: unknown; }>(event); - if (typeof body.body !== "string") { - throw createError({ statusCode: 400, statusMessage: "无效请求" }); - } - - let parentId: number | null; - if (body.parentId === undefined || body.parentId === null) { - parentId = null; - } else if (typeof body.parentId === "number" && Number.isFinite(body.parentId)) { - parentId = body.parentId; - } else { - throw createError({ statusCode: 400, statusMessage: "无效请求" }); - } + const parsed = parsePublicPostCreateCommentBody(body); const newCommentId = await createComment({ postId: ctx.id, ownerUserId: ctx.userId, - parentId, + parentId: parsed.parentId, viewer, - guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, - guestEmail: typeof body.guestEmail === "string" ? body.guestEmail : undefined, - guestIsAnonymous: body.guestIsAnonymous === true, - body: body.body, + guestDisplayName: parsed.guestDisplayName, + guestEmail: parsed.guestEmail, + guestIsAnonymous: parsed.guestIsAnonymous, + body: parsed.body, }); return R.success({ id: newCommentId }); diff --git a/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts index a1742cd..c931c71 100644 --- a/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts +++ b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts @@ -2,6 +2,7 @@ import { getRequestIP } from "h3"; import { getUnlistedPostCommentContext } from "#server/service/posts"; import { createComment } from "#server/service/post-comments"; import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; +import { parseUnlistedCreateCommentBody } from "#server/api/public/comments-create-body"; export default defineEventHandler(async (event) => { const publicSlug = event.context.params?.publicSlug; @@ -31,28 +32,17 @@ export default defineEventHandler(async (event) => { body: unknown; }>(event); - if (typeof body.body !== "string") { - throw createError({ statusCode: 400, statusMessage: "无效请求" }); - } - - let parentId: number | null; - if (body.parentId === undefined || body.parentId === null) { - parentId = null; - } else if (typeof body.parentId === "number" && Number.isFinite(body.parentId)) { - parentId = body.parentId; - } else { - throw createError({ statusCode: 400, statusMessage: "无效请求" }); - } + const parsed = parseUnlistedCreateCommentBody(body); const newCommentId = await createComment({ postId: ctx.id, ownerUserId: ctx.userId, - parentId, + parentId: parsed.parentId, viewer, - guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, - guestEmail: typeof body.guestEmail === "string" ? body.guestEmail : undefined, - guestIsAnonymous: body.guestIsAnonymous === true, - body: body.body, + guestDisplayName: parsed.guestDisplayName, + guestEmail: parsed.guestEmail, + guestIsAnonymous: parsed.guestIsAnonymous, + body: parsed.body, }); return R.success({ id: newCommentId }); diff --git a/server/service/post-comments/guest-fields.test.ts b/server/service/post-comments/guest-fields.test.ts new file mode 100644 index 0000000..601632a --- /dev/null +++ b/server/service/post-comments/guest-fields.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { GuestCommentValidationError } from "../../utils/post-comment-guest"; +import { resolveGuestFields } from "./guest-fields"; + +describe("resolveGuestFields", () => { + test("guest non-anonymous without email throws", () => { + expect(() => + resolveGuestFields({ + viewerPresent: false, + guestIsAnonymous: false, + }), + ).toThrow(GuestCommentValidationError); + }); + + test("guest anonymous with empty email passes", () => { + expect( + resolveGuestFields({ + viewerPresent: false, + guestEmail: "", + guestIsAnonymous: true, + }), + ).toEqual({ guestEmail: null, guestIsAnonymous: true }); + }); + + test("logged-in viewer ignores guest fields", () => { + expect( + resolveGuestFields({ + viewerPresent: true, + guestEmail: "guest@example.com", + guestIsAnonymous: true, + }), + ).toEqual({ guestEmail: null, guestIsAnonymous: false }); + }); + + test("legacy payload without guest email fields falls back to anonymous", () => { + expect( + resolveGuestFields({ + viewerPresent: false, + }), + ).toEqual({ guestEmail: null, guestIsAnonymous: true }); + }); +}); diff --git a/server/service/post-comments/guest-fields.ts b/server/service/post-comments/guest-fields.ts new file mode 100644 index 0000000..57f13fd --- /dev/null +++ b/server/service/post-comments/guest-fields.ts @@ -0,0 +1,23 @@ +import { validateGuestCommentEmail } from "../../utils/post-comment-guest"; + +export type ResolvedGuestFields = { + guestEmail: string | null; + guestIsAnonymous: boolean; +}; + +export function resolveGuestFields(input: { + viewerPresent: boolean; + guestEmail?: string; + guestIsAnonymous?: boolean; +}): ResolvedGuestFields { + if (input.viewerPresent) { + return { guestEmail: null, guestIsAnonymous: false }; + } + + // 兼容过渡策略:旧客户端尚未上送 guestEmail/guestIsAnonymous 时,按匿名处理,避免发布窗口内全量 400。 + // 待所有客户端完成升级后可移除此分支,恢复严格“默认非匿名且邮箱必填”语义。 + const isLegacyGuestPayload = input.guestEmail == null && input.guestIsAnonymous === undefined; + const guestIsAnonymous = input.guestIsAnonymous === true || isLegacyGuestPayload; + const guestEmail = validateGuestCommentEmail(input.guestEmail, guestIsAnonymous); + return { guestEmail, guestIsAnonymous }; +} diff --git a/server/service/post-comments/index.ts b/server/service/post-comments/index.ts index 32fdad6..037e1a3 100644 --- a/server/service/post-comments/index.ts +++ b/server/service/post-comments/index.ts @@ -7,9 +7,9 @@ import { GuestCommentValidationError, normalizeGuestDisplayName, validateGuestCommentBody, - validateGuestCommentEmail, } from "#server/utils/post-comment-guest"; import type { MinimalUser } from "#server/service/auth"; +import { resolveGuestFields } from "./guest-fields"; export type CommentTreeNode = { id: number; @@ -166,10 +166,15 @@ export async function createComment(input: { guestIsAnonymous = false; } else { try { - guestIsAnonymous = input.guestIsAnonymous === true; guestDisplayName = normalizeGuestDisplayName(input.guestDisplayName ?? ""); body = validateGuestCommentBody(input.body); - guestEmail = validateGuestCommentEmail(input.guestEmail, guestIsAnonymous); + const resolved = resolveGuestFields({ + viewerPresent: false, + guestEmail: input.guestEmail, + guestIsAnonymous: input.guestIsAnonymous, + }); + guestEmail = resolved.guestEmail; + guestIsAnonymous = resolved.guestIsAnonymous; } catch (e) { if (e instanceof GuestCommentValidationError) { throw createError({ statusCode: 400, statusMessage: e.message });