From ddca2f8cb30d86be1b578a6b6dd4af386aefcab2 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 5 Aug 2026 15:03:06 +0800 Subject: [PATCH] fix: populate topic author in message list GET /messages always returned the empty placeholder for topic.author because getMessageRelations handed back a raw topic row that has no author relation; the messageDTOSchema contract declares a real author and the route test mocked one, hiding the gap. Fetch the topic author in getMessageRelations and attach it, so the web message list can show who owns the topic. Covered by the new message-relations test, which fails against the previous implementation. --- apps/api/src/lib/message.ts | 4 +- apps/api/src/routes/message.ts | 4 +- apps/api/test/message-relations.test.ts | 67 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 apps/api/test/message-relations.test.ts diff --git a/apps/api/src/lib/message.ts b/apps/api/src/lib/message.ts index 0036d42..afd7aec 100644 --- a/apps/api/src/lib/message.ts +++ b/apps/api/src/lib/message.ts @@ -100,10 +100,12 @@ export async function getMessageRelations(msg: typeof messages.$inferSelect) { msg.topicId ? topicQueries.getById(msg.topicId) : null, msg.replyId ? replyQueries.getById(msg.replyId) : null, ]); + const topicAuthor = topic ? await userQueries.getById(topic.authorId) : null; + return { ...msg, author, - topic, + topic: topic ? { ...topic, author: topicAuthor } : null, reply, isInvalid: !author || !topic, }; diff --git a/apps/api/src/routes/message.ts b/apps/api/src/routes/message.ts index 71d75ef..ad51295 100644 --- a/apps/api/src/routes/message.ts +++ b/apps/api/src/routes/message.ts @@ -75,7 +75,9 @@ message.openapi(listMessagesRoute, async (c) => { author: { loginname: author.loginname, avatar_url: author.avatar ?? "" }, topic: { id: String(topic.id), - author: { loginname: "", avatar_url: "" }, + author: topic.author + ? { loginname: topic.author.loginname, avatar_url: topic.author.avatar ?? "" } + : { loginname: "", avatar_url: "" }, title: topic.title ?? "", last_reply_at: topic.lastReplyAt ? topic.lastReplyAt.toISOString() : null, }, diff --git a/apps/api/test/message-relations.test.ts b/apps/api/test/message-relations.test.ts new file mode 100644 index 0000000..7284553 --- /dev/null +++ b/apps/api/test/message-relations.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + userGetById: vi.fn(), + topicGetById: vi.fn(), + replyGetById: vi.fn(), +})); + +vi.mock("../src/lib/db", () => ({ + getDb: vi.fn(), + userQueries: { getById: mocks.userGetById }, + topicQueries: { getById: mocks.topicGetById }, + replyQueries: { getById: mocks.replyGetById }, +})); + +vi.mock("../src/lib/mail", () => ({ + sendAtNotifyMail: vi.fn(), + sendReplyNotifyMail: vi.fn(), +})); + +import { getMessageRelations } from "../src/lib/message"; + +const messageRow = { + id: 1, + type: "reply", + masterId: 10, + authorId: 20, + topicId: 2, + replyId: null, + hasRead: false, + createAt: new Date("2026-08-05T00:00:00.000Z"), +}; + +const actor = { id: 20, loginname: "alice", avatar: "https://example.com/alice.png" }; +const topicAuthor = { id: 30, loginname: "bob", avatar: "https://example.com/bob.png" }; +const topic = { id: 2, title: "话题标题", authorId: 30, lastReplyAt: null }; + +describe("getMessageRelations", () => { + beforeEach(() => { + mocks.userGetById.mockReset(); + mocks.topicGetById.mockReset(); + mocks.replyGetById.mockReset(); + mocks.userGetById.mockImplementation(async (id: number) => { + if (id === actor.id) return actor; + if (id === topicAuthor.id) return topicAuthor; + return null; + }); + mocks.topicGetById.mockResolvedValue(topic); + mocks.replyGetById.mockResolvedValue(null); + }); + + it("attaches the topic author so /messages can render it", async () => { + const relations = await getMessageRelations(messageRow); + + expect(relations.author).toEqual(actor); + expect(relations.topic?.author).toEqual(topicAuthor); + }); + + it("keeps topic null and flags invalid when the topic is missing", async () => { + mocks.topicGetById.mockResolvedValue(null); + + const relations = await getMessageRelations({ ...messageRow, topicId: null }); + + expect(relations.topic).toBeNull(); + expect(relations.isInvalid).toBe(true); + }); +});