import { EventEmitter } from 'node:events'; import { PassThrough, Readable } from 'node:stream'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import type { ChildProcess } from 'node:child_process'; import type { IncomingMessage, ServerResponse } from 'node:http'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DRAWING_API_PATH, drawingLauncherPlugin } from '../랜딩페이지/drawing-launcher'; class Worker extends EventEmitter { stdout = new PassThrough(); stderr = new PassThrough(); kill = vi.fn(); send(value: object) { this.stdout.write(JSON.stringify(value) + '\n'); } } const base = path.resolve('.verify'); const roots: string[] = []; function fixture(options: { missing?: boolean; fail?: boolean; platform?: NodeJS.Platform } = {}) { mkdirSync(base, { recursive: true }); const root = mkdtempSync(path.join(base, 'drawing-api-')); roots.push(root); const folder = path.join(root, 'DWG & 도면'); mkdirSync(folder); const workers: Worker[] = []; const start = vi.fn((_file: string, args: string[]) => { const worker = new Worker(); workers.push(worker); queueMicrotask(() => { if (options.fail) worker.emit('error', new Error('worker unavailable')); else if (args[2] === 'choose') { worker.send({ type: 'folder', folder }); worker.emit('close', 0); } }); return worker as unknown as ChildProcess; }); const plugin = drawingLauncherPlugin(root, { platform: options.platform ?? 'win32', start, exists: file => options.missing ? false : file.endsWith('python.exe') || file.endsWith('drawing-worker.py') || existsSync(file), }); let handle!: (req: IncomingMessage, res: ServerResponse, next: () => void) => void; (plugin.configureServer as Function)({ middlewares: { use(fn: typeof handle) { handle = fn; } } }); const request = (action: string, body: object = {}, overrides: { method?: string; origin?: string; action?: string; address?: string; host?: string; raw?: string } = {}) => new Promise<{ status: number; body: any; bytes?: Buffer }>(resolve => { const req = Readable.from([overrides.raw ?? JSON.stringify(body)]) as unknown as IncomingMessage; Object.assign(req, { url: `${DRAWING_API_PATH}/${action}`, method: overrides.method ?? 'POST', headers: { host: overrides.host ?? '127.0.0.1:5173', origin: overrides.origin ?? 'http://127.0.0.1:5173', 'x-pierzainer-action': overrides.action ?? 'drawing-extractor', }, socket: { remoteAddress: overrides.address ?? '127.0.0.1' } }); let status = 0, json = true, sent = false; const res = { get headersSent() { return sent; }, writeHead(code: number, headers: Record) { status = code; json = headers['Content-Type'].includes('application/json'); sent = true; }, end(text: string | Buffer) { resolve(json ? { status, body: JSON.parse(String(text)) } : { status, body: null, bytes: Buffer.from(text) }); } } as unknown as ServerResponse; handle(req, res, () => resolve({ status: 404, body: {} })); }); const finish = (outside = false) => { const args = start.mock.calls[start.mock.calls.length - 1][1]; const output = args[args.indexOf('--output')+1]; const file = path.join(outside ? root : output, '결과.xlsx'); writeFileSync(file, 'test-workbook'); const worker = workers[workers.length-1]; worker.send({ type: 'log', message: '도면 판독 완료' }); worker.send({ type: 'result', result: { status: 'needs_review', output_dir: output, review_count: 2, failed_file_count: 0, workbooks: [{ name: '테스트 교량', xlsx_path: file, review_count: 2 }] } }); worker.emit('close', 0); return file; }; return { root, folder, request, start, workers, finish }; } afterEach(() => { for (const root of roots.splice(0)) { if (!root.startsWith(base + path.sep)) throw new Error('Unsafe test fixture path'); rmSync(root, { recursive: true, force: true }); } }); describe('in-app drawing extraction API', () => { it('only accepts local same-origin POST requests with the application header', async () => { const f = fixture(); expect((await f.request('start', {}, { method: 'GET' })).status).toBe(405); for (const headers of [{ origin: 'https://other.example' }, { action: '' }, { address: '192.168.0.2' }, { host: 'other.example' }]) expect((await f.request('start', {}, headers)).status).toBe(403); expect(f.start).not.toHaveBeenCalled(); }); it('returns folder selection without starting the standalone GUI', async () => { const f = fixture(); expect((await f.request('choose-folder')).body.folder).toBe(f.folder); expect(f.start.mock.calls[0][1][2]).toBe('choose'); expect(f.start.mock.calls[0][1].join(' ')).not.toContain('desktop_app.py'); expect((await f.request('status')).body.job).toBeNull(); }); it('tracks progress, preserves review warnings, and serves only its generated workbook', async () => { const f = fixture(); const response = await f.request('start', { folder: f.folder }); expect(response.status).toBe(202); const jobId = response.body.job.id; expect(f.start.mock.calls[0][1][3]).toBe(f.folder); expect((await f.request('workbook', { jobId, index: 0 })).status).toBe(400); const file = f.finish(); const state = (await f.request('status', { jobId })).body.job; expect(state.status).toBe('needs_review'); expect(state.logs).toContain('도면 판독 완료'); expect(state.result.reviewCount).toBe(2); const download = await f.request('workbook', { jobId, index: 0, path: 'unrelated.txt' }); expect(download.status).toBe(200); expect(download.bytes).toEqual(readFileSync(file)); expect((await f.request('workbook', { jobId, index: -1 })).status).toBe(400); expect((await f.request('workbook', { jobId, index: 20 })).status).toBe(404); }); it('does not run two extraction jobs concurrently', async () => { const f = fixture(); const states = await Promise.all([f.request('start', { folder: f.folder }), f.request('start', { folder: f.folder })]); expect(states.map(s => s.status).sort()).toEqual([202, 409]); expect(f.start).toHaveBeenCalledTimes(1); f.finish(); }); it('refuses files outside the controlled output even if a worker lists them', async () => { const f = fixture(); const { body } = await f.request('start', { folder: f.folder }); f.finish(true); expect((await f.request('workbook', { jobId: body.job.id, index: 0 })).status).toBe(403); }); it('reports worker failures so the popup can retry', async () => { const f = fixture({ fail: true }); const { body } = await f.request('start', { folder: f.folder }); const job = (await f.request('status', { jobId: body.job.id })).body.job; expect(job.status).toBe('error'); expect(job.error).toContain('worker unavailable'); }); it('validates folders, JSON, and request size before starting a worker', async () => { const f = fixture(); expect((await f.request('start', { folder: 'missing-folder' })).status).toBe(400); expect((await f.request('start', {}, { raw: 'bad-json' })).status).toBe(400); expect((await f.request('start', {}, { raw: 'a'.repeat(8193) })).status).toBe(413); expect(f.start).not.toHaveBeenCalled(); expect((await fixture({ missing: true }).request('status')).status).toBe(503); expect((await fixture({ platform: 'linux' }).request('status')).status).toBe(501); }); });