160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { handleDataServiceRoutes } from '@electron/api/routes/data-service';
|
|
import type { HostApiContext } from '@electron/api/context';
|
|
import type { DataServiceOperations } from '@electron/services/data-service-client';
|
|
|
|
function request(
|
|
method: string,
|
|
body?: unknown,
|
|
headers: Record<string, string> = {},
|
|
): IncomingMessage {
|
|
const req = new EventEmitter();
|
|
const raw = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
|
|
Object.assign(req, {
|
|
method,
|
|
headers: {
|
|
...(raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw, 'utf8')) }),
|
|
...headers,
|
|
},
|
|
[Symbol.asyncIterator]: async function* () {
|
|
if (raw !== undefined) yield Buffer.from(raw, 'utf8');
|
|
},
|
|
});
|
|
return req as IncomingMessage;
|
|
}
|
|
|
|
function response() {
|
|
const chunks: string[] = [];
|
|
const headers = new Map<string, string>();
|
|
const res = new EventEmitter();
|
|
Object.assign(res, {
|
|
statusCode: 0,
|
|
setHeader: vi.fn((name: string, value: string) => headers.set(name.toLowerCase(), value)),
|
|
end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }),
|
|
});
|
|
return {
|
|
res: res as unknown as ServerResponse,
|
|
get status() { return (res as { statusCode: number }).statusCode; },
|
|
header: (name: string) => headers.get(name.toLowerCase()),
|
|
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
|
|
};
|
|
}
|
|
|
|
function success<T>(data: T): { success: true; status: 200; code: null; error: null; retryable: false; data: T } {
|
|
return { success: true, status: 200, code: null, error: null, retryable: false, data };
|
|
}
|
|
|
|
function setup(overrides: Partial<Record<keyof DataServiceOperations, ReturnType<typeof vi.fn>>> = {}) {
|
|
const operations = {
|
|
configure: vi.fn().mockResolvedValue(success({})),
|
|
inspect: vi.fn().mockResolvedValue(success({})),
|
|
listProjects: vi.fn().mockResolvedValue(success({ items: [], total: 0, instance_limit: 20 })),
|
|
getDocument: vi.fn().mockResolvedValue(success({})),
|
|
listDocuments: vi.fn().mockResolvedValue(success({ items: [], next_cursor: null, limit: 50 })),
|
|
putDocument: vi.fn().mockResolvedValue(success({})),
|
|
deleteDocument: vi.fn().mockResolvedValue(success(null)),
|
|
removeCollection: vi.fn().mockResolvedValue(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } })),
|
|
reset: vi.fn().mockResolvedValue(success({})),
|
|
removeProject: vi.fn().mockResolvedValue(success({ removed: true })),
|
|
...overrides,
|
|
} as unknown as DataServiceOperations;
|
|
const ctx = { codingProducts: { dataService: operations } } as unknown as HostApiContext;
|
|
return { operations, ctx };
|
|
}
|
|
|
|
async function invoke(
|
|
ctx: HostApiContext,
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
headers: Record<string, string> = {},
|
|
) {
|
|
const target = response();
|
|
const handled = await handleDataServiceRoutes(
|
|
request(method, body, headers),
|
|
target.res,
|
|
new URL(`http://localhost${path}`),
|
|
ctx,
|
|
);
|
|
return { handled, ...target, payload: target.json() };
|
|
}
|
|
|
|
describe('Data Service Host routes', () => {
|
|
it('uses the owner-wide listing without an active-project argument', async () => {
|
|
const { operations, ctx } = setup();
|
|
|
|
const result = await invoke(ctx, 'GET', '/api/works/data-service/projects');
|
|
|
|
expect(result.handled).toBe(true);
|
|
expect(result.status).toBe(200);
|
|
expect(result.header('cache-control')).toBe('private, no-store');
|
|
expect(operations.listProjects).toHaveBeenCalledOnce();
|
|
expect(result.payload).toMatchObject({ success: true, data: { total: 0 } });
|
|
});
|
|
|
|
it('passes only strict collection input to active-project configure', async () => {
|
|
const { operations, ctx } = setup();
|
|
|
|
await invoke(ctx, 'PUT', '/api/works/data-service/project', { collections: ['todos', 'settings'] }, {
|
|
'content-type': 'application/json',
|
|
});
|
|
|
|
expect(operations.configure).toHaveBeenCalledWith({ collections: ['todos', 'settings'] });
|
|
});
|
|
|
|
it('requires the literal confirmation before removing the active project', async () => {
|
|
const { operations, ctx } = setup();
|
|
|
|
const rejected = await invoke(ctx, 'DELETE', '/api/works/data-service/project');
|
|
const accepted = await invoke(ctx, 'DELETE', '/api/works/data-service/project?confirmed=true');
|
|
|
|
expect(rejected.payload).toMatchObject({ success: false, status: 400, code: 'confirmation_required', data: null });
|
|
expect(operations.removeProject).toHaveBeenCalledOnce();
|
|
expect(operations.removeProject).toHaveBeenCalledWith({ confirmed: true });
|
|
expect(accepted.payload).toMatchObject({ success: true, status: 200 });
|
|
});
|
|
|
|
it('projects a document precondition and rejects extra request fields locally', async () => {
|
|
const { operations, ctx } = setup();
|
|
|
|
await invoke(
|
|
ctx,
|
|
'PUT',
|
|
'/api/works/data-service/project/collections/todos/documents/todo-1',
|
|
{ data: { done: false } },
|
|
{ 'content-type': 'application/json', 'if-match': '"7"' },
|
|
);
|
|
const rejected = await invoke(
|
|
ctx,
|
|
'PUT',
|
|
'/api/works/data-service/project/collections/todos/documents/todo-2',
|
|
{ data: {}, extra: true },
|
|
{ 'content-type': 'application/json' },
|
|
);
|
|
|
|
expect(operations.putDocument).toHaveBeenCalledWith({
|
|
collection: 'todos',
|
|
document_id: 'todo-1',
|
|
data: { done: false },
|
|
if_revision: 7,
|
|
});
|
|
expect(rejected.payload).toMatchObject({ success: false, status: 422, code: 'invalid_request', data: null });
|
|
expect(operations.putDocument).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('keeps malformed paths and unsupported query keys out of the operations adapter', async () => {
|
|
const { operations, ctx } = setup();
|
|
|
|
const result = await invoke(
|
|
ctx,
|
|
'GET',
|
|
'/api/works/data-service/project/collections/todos/documents?limit=101&unexpected=x',
|
|
);
|
|
|
|
expect(result.payload).toMatchObject({ success: false, status: 422, code: 'invalid_request', data: null });
|
|
expect(operations.listDocuments).not.toHaveBeenCalled();
|
|
});
|
|
});
|