From 014eb1b661aa359292c9bcec8c4080515d312a8d Mon Sep 17 00:00:00 2001 From: xiajiahang Date: Sun, 11 Oct 2026 19:40:09 +0800 Subject: [PATCH] fix: keep upstream page JSON failures as service errors --- src/server/page-routes.ts | 35 +++++++++++------------ tests/page-routes.test.ts | 60 +++++++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/server/page-routes.ts b/src/server/page-routes.ts index 5718fdc7..ef24a1a0 100644 --- a/src/server/page-routes.ts +++ b/src/server/page-routes.ts @@ -29,7 +29,7 @@ export function pageRoutes(platform: Platform) { app.post('/conversations/:id/reviewed-page', async (c) => { const data = pageReviewSchema .extend({ toolCallId: z.string().min(1).max(200) }) - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json({ error: 'Enter a valid page draft.' }, 400); const thread = platform.workspace.requireThread(c.req.param('id')); @@ -68,7 +68,7 @@ export function pageRoutes(platform: Platform) { ), ); app.post('/spaces/:spaceId/pages', async (c) => { - const data = pageInput.safeParse(await c.req.json()); + const data = pageInput.safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json( { @@ -83,7 +83,7 @@ export function pageRoutes(platform: Platform) { ); }); app.patch('/spaces/:spaceId/pages/:id', async (c) => { - const data = pagePatch.safeParse(await c.req.json()); + const data = pagePatch.safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json( { error: 'A valid page patch and expectedRevision are required.' }, @@ -110,7 +110,7 @@ export function pageRoutes(platform: Platform) { const data = z .object({ dotId: z.string().min(1) }) .strict() - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json({ error: 'Choose a specialist.' }, 400); return c.json( await platform.pages.conversation( @@ -127,7 +127,7 @@ export function pageRoutes(platform: Platform) { parentId: z.string().nullable().default(null), }) .strict() - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json({ error: 'Enter a valid page title and parent.' }, 400); return c.json( @@ -140,20 +140,17 @@ export function pageRoutes(platform: Platform) { ); }); app.onError((error, c) => - error instanceof SyntaxError - ? c.json({ error: 'Invalid JSON request.' }, 400) - : error instanceof PageError - ? c.json({ error: error.message }, error.status) - : error.message === - 'Conversation does not belong to this Dot and owner.' - ? c.json({ error: error.message }, 404) - : c.json( - { - error: - 'Page operation could not complete. Check Intelligence setup or retry; your draft has not been discarded.', - }, - 503, - ), + error instanceof PageError + ? c.json({ error: error.message }, error.status) + : error.message === 'Conversation does not belong to this Dot and owner.' + ? c.json({ error: error.message }, 404) + : c.json( + { + error: + 'Page operation could not complete. Check Intelligence setup or retry; your draft has not been discarded.', + }, + 503, + ), ); return app; } diff --git a/tests/page-routes.test.ts b/tests/page-routes.test.ts index a5af8e4b..210e5992 100644 --- a/tests/page-routes.test.ts +++ b/tests/page-routes.test.ts @@ -409,15 +409,37 @@ it.each([ }, ); -it('keeps real Intelligence failures as 503 without exposing details', async () => { +it.each([ + new Error('Provider secret'), + new SyntaxError('Unexpected token in provider secret'), +])( + 'keeps real Intelligence failures as 503 without exposing details: %s', + async (error) => { + const { ws, app, platform } = fixture(); + ws.bindThread('valid-thread', ws.dots()[0].id, 'Conversation'); + vi.spyOn(platform.pages, 'saveConversation').mockRejectedValueOnce(error); + const response = await app.request( + '/api/conversations/valid-thread/page', + request({ title: 'Draft' }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: + 'Page operation could not complete. Check Intelligence setup or retry; your draft has not been discarded.', + }); + }, +); + +it('keeps page conversation response parsing failures as 503', async () => { const { ws, app, platform } = fixture(); - ws.bindThread('valid-thread', ws.dots()[0].id, 'Conversation'); - vi.spyOn(platform.pages, 'saveConversation').mockRejectedValueOnce( - new Error('Provider secret'), + const dot = ws.dots()[0]; + const page = ws.pages.create(dot.spaceId, { title: 'Document' }); + vi.spyOn(platform.pages, 'conversation').mockRejectedValueOnce( + new SyntaxError('Unexpected token in provider secret'), ); const response = await app.request( - '/api/conversations/valid-thread/page', - request({ title: 'Draft' }), + `/api/spaces/${dot.spaceId}/pages/${page.id}/conversation`, + request({ dotId: dot.id }), ); expect(response.status).toBe(503); expect(await response.json()).toEqual({ @@ -426,6 +448,32 @@ it('keeps real Intelligence failures as 503 without exposing details', async () }); }); +it.each([ + ['POST', '/spaces/space/pages'], + ['PATCH', '/spaces/space/pages/page'], + ['POST', '/spaces/space/pages/page/conversation'], + ['POST', '/conversations/thread/reviewed-page'], + ['POST', '/conversations/thread/page'], +])( + 'rejects malformed request JSON before any page operation on %s %s', + async (method, path) => { + const { app, platform, ws } = fixture(); + const conversation = vi.spyOn(platform.pages, 'conversation'); + const save = vi.spyOn(platform.pages, 'saveConversation'); + const create = vi.spyOn(ws.pages, 'create'); + const update = vi.spyOn(ws.pages, 'update'); + const review = vi.spyOn(ws.pages, 'createReviewed'); + const response = await app.request(`/api${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: '{', + }); + expect(response.status).toBe(400); + for (const operation of [conversation, save, create, update, review]) + expect(operation).not.toHaveBeenCalled(); + }, +); + it('answers 404, not 503, for a conversation or call that does not exist', async () => { const { app } = fixture(); for (const path of [