feat(daemon): implement page CRUD routes (Task 8)
- Add routes/pages.ts with pageRoutes handler for POST/GET/DELETE /pages - Handle POST /pages (create), GET /pages (list), GET /pages/:id, DELETE /pages/:id - Use safeStringify for all JSON output, proper error codes for 400/404/409/500 - Update server.ts to accept optional BrowserManager and register pageRoutes - Add 13 unit tests with mock BrowserManager covering all endpoints
This commit is contained in:
@@ -0,0 +1,291 @@
|
|||||||
|
// Page CRUD routes integration tests with mock BrowserManager / 页面 CRUD 路由集成测试,使用模拟浏览器管理器
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { startServer } from '../server.js';
|
||||||
|
import type { PageInfo } from '@visionl/core';
|
||||||
|
|
||||||
|
// Mock BrowserManager — mimics BrowserManager behavior without Playwright / 模拟浏览器管理器
|
||||||
|
function createMockBM() {
|
||||||
|
const pages = new Map<string, PageInfo>();
|
||||||
|
const aliases = new Map<string, string>();
|
||||||
|
|
||||||
|
const mock = {
|
||||||
|
async createPage(pageUrl: string, alias?: string): Promise<PageInfo> {
|
||||||
|
if (alias && aliases.has(alias)) {
|
||||||
|
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new URL(pageUrl);
|
||||||
|
} catch {
|
||||||
|
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
|
||||||
|
}
|
||||||
|
const id = 'p_' + Math.random().toString(16).slice(2, 10);
|
||||||
|
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
|
||||||
|
pages.set(id, info);
|
||||||
|
if (alias) aliases.set(alias, id);
|
||||||
|
return info;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPage(idOrAlias: string) {
|
||||||
|
const id = aliases.get(idOrAlias) || idOrAlias;
|
||||||
|
const info = pages.get(id);
|
||||||
|
if (!info) return undefined;
|
||||||
|
return { info, page: null, context: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
listPages(): PageInfo[] {
|
||||||
|
return Array.from(pages.values());
|
||||||
|
},
|
||||||
|
|
||||||
|
async closePage(idOrAlias: string): Promise<void> {
|
||||||
|
const id = aliases.get(idOrAlias) || idOrAlias;
|
||||||
|
if (!pages.has(id)) {
|
||||||
|
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
const info = pages.get(id)!;
|
||||||
|
if (info.alias) aliases.delete(info.alias);
|
||||||
|
pages.delete(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mock, pages };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('page CRUD routes', () => {
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
const port = 19529;
|
||||||
|
|
||||||
|
const { mock } = createMockBM();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startServer(port, mock as any);
|
||||||
|
baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each test gets a fresh mock state via re-creating mock internals / 每个测试用例使用全新模拟状态
|
||||||
|
let mockPages: Map<string, PageInfo>;
|
||||||
|
let mockAliases: Map<string, string>;
|
||||||
|
|
||||||
|
function resetMock() {
|
||||||
|
mockPages = new Map();
|
||||||
|
mockAliases = new Map();
|
||||||
|
|
||||||
|
mock.createPage = async function (pageUrl: string, alias?: string): Promise<PageInfo> {
|
||||||
|
if (alias && mockAliases.has(alias)) {
|
||||||
|
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new URL(pageUrl);
|
||||||
|
} catch {
|
||||||
|
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
|
||||||
|
}
|
||||||
|
const id = 'p_' + Math.random().toString(16).slice(2, 10);
|
||||||
|
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
|
||||||
|
mockPages.set(id, info);
|
||||||
|
if (alias) mockAliases.set(alias, id);
|
||||||
|
return info;
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.getPage = function (idOrAlias: string) {
|
||||||
|
const id = mockAliases.get(idOrAlias) || idOrAlias;
|
||||||
|
const info = mockPages.get(id);
|
||||||
|
if (!info) return undefined;
|
||||||
|
return { info, page: null, context: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.listPages = function (): PageInfo[] {
|
||||||
|
return Array.from(mockPages.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.closePage = async function (idOrAlias: string): Promise<void> {
|
||||||
|
const id = mockAliases.get(idOrAlias) || idOrAlias;
|
||||||
|
if (!mockPages.has(id)) {
|
||||||
|
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
const info = mockPages.get(id)!;
|
||||||
|
if (info.alias) mockAliases.delete(info.alias);
|
||||||
|
mockPages.delete(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset mock state before each test / 每个测试前重置模拟状态
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMock();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /pages creates a page and returns 200', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com', alias: 'test' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
expect(json.data.id).toMatch(/^p_/);
|
||||||
|
expect(json.data.url).toBe('https://example.com');
|
||||||
|
expect(json.data.alias).toBe('test');
|
||||||
|
expect(json.data.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /pages with alias creates page accessible by alias', async () => {
|
||||||
|
const createRes = await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com', alias: 'home' }),
|
||||||
|
});
|
||||||
|
const createJson = await createRes.json();
|
||||||
|
|
||||||
|
const getRes = await fetch(`${baseUrl}/pages/home`);
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getJson = await getRes.json();
|
||||||
|
expect(getJson.data.id).toBe(createJson.data.id);
|
||||||
|
expect(getJson.data.alias).toBe('home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /pages without alias works', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
expect(json.data.alias).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /pages with invalid URL returns 400', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'not-a-valid-url' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
expect(json.error.code).toBe('INVALID_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /pages with duplicate alias returns 409', async () => {
|
||||||
|
await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com', alias: 'dup' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://other.com', alias: 'dup' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
expect(json.error.code).toBe('ALIAS_EXISTS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /pages lists all created pages', async () => {
|
||||||
|
await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com' }),
|
||||||
|
});
|
||||||
|
await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://other.com' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/pages`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
expect(json.data).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /pages returns empty array when no pages', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/pages`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
expect(json.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /pages/:id returns page by ID', async () => {
|
||||||
|
const createRes = await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com' }),
|
||||||
|
});
|
||||||
|
const { data: created } = await createRes.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/pages/${created.id}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
expect(json.data.id).toBe(created.id);
|
||||||
|
expect(json.data.url).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /pages/:id returns 404 for unknown page', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/pages/nonexistent`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
expect(json.error.code).toBe('PAGE_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /pages/:id removes page and returns 200', async () => {
|
||||||
|
const createRes = await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com' }),
|
||||||
|
});
|
||||||
|
const { data: created } = await createRes.json();
|
||||||
|
|
||||||
|
const deleteRes = await fetch(`${baseUrl}/pages/${created.id}`, { method: 'DELETE' });
|
||||||
|
expect(deleteRes.status).toBe(200);
|
||||||
|
const deleteJson = await deleteRes.json();
|
||||||
|
expect(deleteJson.ok).toBe(true);
|
||||||
|
expect(deleteJson.data).toBeNull();
|
||||||
|
|
||||||
|
// Verify page is gone
|
||||||
|
const getRes = await fetch(`${baseUrl}/pages/${created.id}`);
|
||||||
|
expect(getRes.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /pages/:id returns 404 for unknown page', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/pages/nonexistent`, { method: 'DELETE' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
expect(json.error.code).toBe('PAGE_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /pages by alias works', async () => {
|
||||||
|
await fetch(`${baseUrl}/pages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com', alias: 'myalias' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteRes = await fetch(`${baseUrl}/pages/myalias`, { method: 'DELETE' });
|
||||||
|
expect(deleteRes.status).toBe(200);
|
||||||
|
|
||||||
|
const getRes = await fetch(`${baseUrl}/pages/myalias`);
|
||||||
|
expect(getRes.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('health check still works alongside page routes', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/health`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json).toEqual({ status: 'ok' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { BrowserManager } from '../browser-manager.js';
|
||||||
|
import { safeStringify } from '@visionl/core';
|
||||||
|
|
||||||
|
type RouteHandler = (req: IncomingMessage, res: ServerResponse) => boolean;
|
||||||
|
|
||||||
|
export function pageRoutes(bm: BrowserManager): RouteHandler {
|
||||||
|
return (req, res) => {
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
const path = url.pathname;
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
// POST /pages
|
||||||
|
if (req.method === 'POST' && path === '/pages') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { url: pageUrl, alias } = JSON.parse(body);
|
||||||
|
const info = await bm.createPage(pageUrl, alias);
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: info }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(err.code === 'ALIAS_EXISTS' ? 409 : 400);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /pages
|
||||||
|
if (req.method === 'GET' && path === '/pages') {
|
||||||
|
const pages = bm.listPages();
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: pages }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /pages/:id
|
||||||
|
// DELETE /pages/:id
|
||||||
|
if (segments[0] === 'pages' && segments.length === 2) {
|
||||||
|
const id = segments[1];
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const entry = bm.getPage(id);
|
||||||
|
if (!entry) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: entry.info }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
bm.closePage(id).then(() => {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: null }));
|
||||||
|
}).catch((err: any) => {
|
||||||
|
res.writeHead(err.code === 'PAGE_NOT_FOUND' ? 404 : 500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { healthRoute } from './routes/health.js';
|
import { healthRoute } from './routes/health.js';
|
||||||
|
import { pageRoutes } from './routes/pages.js';
|
||||||
|
import type { BrowserManager } from './browser-manager.js';
|
||||||
|
|
||||||
const routes = [healthRoute];
|
export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> {
|
||||||
|
const routes = [healthRoute];
|
||||||
|
if (browserManager) {
|
||||||
|
routes.push(pageRoutes(browserManager));
|
||||||
|
}
|
||||||
|
|
||||||
export function startServer(port: number): Promise<http.Server> {
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
|
|||||||
Reference in New Issue
Block a user