From 3154ee23bae0e07107927fbe423539b077370f7a Mon Sep 17 00:00:00 2001 From: fokality Date: Thu, 15 Oct 2026 13:55:23 +0900 Subject: [PATCH] Serve .ipuz downloads as application/octet-stream Android appends ".json" to files served as application/json, so downloading an .ipuz produced "puzzle.ipuz.json". Send the file as application/octet-stream so the .ipuz extension is preserved. Adds a regression test for the response headers of /api/ipuz/[puzzleId]. Fixes #603 Co-Authored-By: Claude Sonnet 5.5 --- app/__tests__/ipuzApi.test.ts | 58 ++++++++++++++++++++++++++++++++ app/pages/api/ipuz/[puzzleId].ts | 2 +- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 app/__tests__/ipuzApi.test.ts diff --git a/app/__tests__/ipuzApi.test.ts b/app/__tests__/ipuzApi.test.ts new file mode 100644 index 00000000..6d33be40 --- /dev/null +++ b/app/__tests__/ipuzApi.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment node + */ + +import { jest } from '@jest/globals'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +const getPuzzle = jest.fn<(id: string) => Promise>(); + +jest.unstable_mockModule('../lib/serverOnly.js', () => ({ getPuzzle })); +jest.unstable_mockModule('../lib/converter.js', () => ({ + exportIpuz: () => new TextEncoder().encode('{}'), +})); + +const { default: handler } = await import('../pages/api/ipuz/[puzzleId].js'); + +function mockResponse() { + const headers: Record = {}; + const res = { + headers, + statusCode: 0, + setHeader: jest.fn((k: string, v: string) => { + headers[k.toLowerCase()] = v; + }), + writeHead: jest.fn((status: number, h: Record) => { + res.statusCode = status; + for (const [k, v] of Object.entries(h)) { + headers[k.toLowerCase()] = v; + } + }), + end: jest.fn(), + status: jest.fn((s: number) => { + res.statusCode = s; + return res; + }), + json: jest.fn(), + }; + return res; +} + +test('ipuz download is not served as application/json', async () => { + // Android appends .json to downloads served as application/json, which + // turns "puzzle.ipuz" into "puzzle.ipuz.json". + getPuzzle.mockResolvedValue({ id: 'abc', t: 'My Puzzle!' }); + const res = mockResponse(); + + await handler( + { query: { puzzleId: 'abc' } } as unknown as NextApiRequest, + res as unknown as NextApiResponse + ); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/octet-stream'); + expect(res.headers['content-disposition']).toBe( + 'inline; filename="My Puzzle.ipuz"' + ); + expect(res.end).toHaveBeenCalled(); +}); diff --git a/app/pages/api/ipuz/[puzzleId].ts b/app/pages/api/ipuz/[puzzleId].ts index d4c14c3b..83ca22eb 100644 --- a/app/pages/api/ipuz/[puzzleId].ts +++ b/app/pages/api/ipuz/[puzzleId].ts @@ -25,6 +25,6 @@ export default async function ipuz(req: NextApiRequest, res: NextApiResponse) { 'Content-Disposition', 'inline; filename="' + puzzle.t.replace(/[^\w ]/g, '') + '.ipuz"' ); - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); res.end(Buffer.from(exportIpuz(puzzle))); }