Browse Source

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
main
npmrun 3 weeks ago
parent
commit
4c108e5cf5
  1. 47
      server/api/public/comments-create-body.test.ts
  2. 51
      server/api/public/comments-create-body.ts
  3. 24
      server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts
  4. 24
      server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts
  5. 42
      server/service/post-comments/guest-fields.test.ts
  6. 23
      server/service/post-comments/guest-fields.ts
  7. 11
      server/service/post-comments/index.ts

47
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",
});
});
});

51
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: "无效请求" });
}

24
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 });

24
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 });

42
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 });
});
});

23
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 };
}

11
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 });

Loading…
Cancel
Save