feat(coding): add Main Data Service Host adapter
This commit is contained in:
304
tests/unit/data-service-client.test.ts
Normal file
304
tests/unit/data-service-client.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createDataServiceOperations,
|
||||
DataServiceCloudClient,
|
||||
} from '@electron/services/data-service-client';
|
||||
|
||||
const projectId = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function jsonResponse(value: unknown, init: ResponseInit = {}): Response {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('content-type', 'application/json');
|
||||
return new Response(JSON.stringify(value), { ...init, headers });
|
||||
}
|
||||
|
||||
function instance(project = projectId) {
|
||||
return {
|
||||
instance_id: '22222222-2222-4222-8222-222222222222',
|
||||
project_id: project,
|
||||
collections: [],
|
||||
usage: { document_count: 0, total_bytes: 0 },
|
||||
limits: {
|
||||
max_collections: 20,
|
||||
max_documents: 5000,
|
||||
max_total_bytes: 20971520,
|
||||
max_document_bytes: 65536,
|
||||
list_default_limit: 50,
|
||||
list_max_limit: 100,
|
||||
list_max_data_bytes: 1048576,
|
||||
mutations_per_minute: 120,
|
||||
},
|
||||
created_at: '2026-08-26T08:00:00Z',
|
||||
updated_at: '2026-08-26T08:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function document() {
|
||||
return {
|
||||
id: 'todo-1',
|
||||
data: { title: 'Ship P0', done: false },
|
||||
revision: 7,
|
||||
created_at: '2026-08-26T08:00:00Z',
|
||||
updated_at: '2026-08-26T08:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('DataServiceCloudClient', () => {
|
||||
it('projects the control-plane route and direct DTO', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
jsonResponse(instance(), { status: 200 }),
|
||||
);
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
apiBaseUrl: 'https://square.example/',
|
||||
});
|
||||
|
||||
const result = await client.configure(projectId, ['todos']);
|
||||
|
||||
expect(result).toMatchObject({ success: true, status: 200, data: instance() });
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
`https://square.example/api/data-service/v1/projects/${projectId}`,
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ collections: ['todos'] }),
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer access-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes and replays exactly once after an authoritative 401', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(jsonResponse(instance(), { status: 200 }));
|
||||
const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => (
|
||||
options?.forceRefresh ? 'refreshed-token' : 'stale-token'
|
||||
));
|
||||
const client = new DataServiceCloudClient({ fetchImpl, getAccessToken });
|
||||
|
||||
const result = await client.inspect(projectId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(getAccessToken).toHaveBeenNthCalledWith(1, { fetchImpl });
|
||||
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
|
||||
expect((fetchImpl.mock.calls[1][1] as RequestInit).headers).toEqual(
|
||||
expect.objectContaining({ Authorization: 'Bearer refreshed-token' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not replay an ambiguous transport failure', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(new Error('socket closed'));
|
||||
const getAccessToken = vi.fn().mockResolvedValue('access-token');
|
||||
const client = new DataServiceCloudClient({ fetchImpl, getAccessToken });
|
||||
|
||||
const result = await client.inspect(projectId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'data_service_unavailable',
|
||||
retryable: true,
|
||||
data: null,
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(getAccessToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps error fields safe while preserving accepted code and context', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
jsonResponse({
|
||||
detail: {
|
||||
code: 'quota_exceeded',
|
||||
message: 'owner secret must not cross the Host boundary',
|
||||
retryable: false,
|
||||
context: {
|
||||
resource: 'bytes',
|
||||
limit: 20971520,
|
||||
current: 20900000,
|
||||
attempted: 80000,
|
||||
owner_user_id: 'private',
|
||||
},
|
||||
},
|
||||
}, { status: 409 }),
|
||||
);
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
});
|
||||
|
||||
const result = await client.putDocument(projectId, {
|
||||
collection: 'todos',
|
||||
document_id: 'todo-1',
|
||||
data: { done: false },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
status: 409,
|
||||
code: 'quota_exceeded',
|
||||
error: 'Data Service quota exceeded',
|
||||
retryable: false,
|
||||
context: { resource: 'bytes', limit: 20971520, current: 20900000, attempted: 80000 },
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes an invalid success DTO', async () => {
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ items: [] })),
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
});
|
||||
|
||||
const result = await client.listProjects();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'upstream_invalid_response',
|
||||
error: 'Data Service returned an invalid response',
|
||||
retryable: false,
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a control-plane response for a different project', async () => {
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(
|
||||
jsonResponse(instance('33333333-3333-4333-8333-333333333333'), { status: 200 }),
|
||||
),
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
});
|
||||
|
||||
const result = await client.inspect(projectId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'upstream_invalid_response',
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed known error fields', async () => {
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(
|
||||
jsonResponse({ detail: { code: 'quota_exceeded', retryable: 'no' } }, { status: 409 }),
|
||||
),
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
});
|
||||
|
||||
const result = await client.inspect(projectId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'upstream_invalid_response',
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes malformed client errors and all upstream 5xx responses safely', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response('private upstream exception', { status: 500 }));
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
});
|
||||
|
||||
const malformed = await client.inspect(projectId);
|
||||
const unavailable = await client.inspect(projectId);
|
||||
|
||||
expect(malformed).toMatchObject({ success: false, status: 502, code: 'upstream_invalid_response', data: null });
|
||||
expect(unavailable).toMatchObject({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'data_service_unavailable',
|
||||
retryable: true,
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects data-plane paths, query parameters, and strong revision headers exactly', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse(document(), { status: 200 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [document()], next_cursor: null, limit: 50 }, { status: 200 }))
|
||||
.mockResolvedValueOnce(jsonResponse(document(), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
|
||||
await client.getDocument(projectId, 'todos', 'todo-1');
|
||||
await client.listDocuments(projectId, 'todos', 50, 'cursor/next');
|
||||
await client.putDocument(projectId, {
|
||||
collection: 'todos',
|
||||
document_id: 'todo-1',
|
||||
data: document().data,
|
||||
if_revision: 7,
|
||||
});
|
||||
await client.deleteDocument(projectId, { collection: 'todos', document_id: 'todo-1', if_revision: 7 });
|
||||
|
||||
expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([
|
||||
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`,
|
||||
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents?limit=50&cursor=cursor%2Fnext`,
|
||||
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`,
|
||||
`https://square.example/api/data/v1/projects/${projectId}/collections/todos/documents/todo-1`,
|
||||
]);
|
||||
expect((fetchImpl.mock.calls[2][1] as RequestInit).headers).toEqual(
|
||||
expect.objectContaining({ 'If-Match': '"7"' }),
|
||||
);
|
||||
expect((fetchImpl.mock.calls[3][1] as RequestInit).headers).toEqual(
|
||||
expect.objectContaining({ 'If-Match': '"7"' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves opaque cursor text when projecting the query', async () => {
|
||||
const listDocuments = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
jsonResponse({ items: [], next_cursor: null, limit: 50 }, { status: 200 }),
|
||||
);
|
||||
const client = new DataServiceCloudClient({
|
||||
fetchImpl: listDocuments,
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
});
|
||||
|
||||
const result = await client.listDocuments(projectId, 'todos', 50, ' cursor ');
|
||||
|
||||
expect(result).toMatchObject({ success: true, status: 200 });
|
||||
expect(listDocuments.mock.calls[0][0]).toContain('cursor=+cursor+');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataServiceOperations', () => {
|
||||
it('derives active project identity for every operation except owner-wide listing', async () => {
|
||||
const client = {
|
||||
listProjects: vi.fn().mockResolvedValue({ success: true, status: 200, code: null, error: null, retryable: false, data: { items: [], total: 0, instance_limit: 20 } }),
|
||||
inspect: vi.fn().mockResolvedValue({ success: true, status: 200, code: null, error: null, retryable: false, data: instance() }),
|
||||
} as unknown as DataServiceCloudClient;
|
||||
const requireActiveRealProjectWithIdentity = vi.fn().mockResolvedValue({
|
||||
project: {},
|
||||
path: 'C:\\projects\\active',
|
||||
projectId,
|
||||
});
|
||||
const operations = createDataServiceOperations({
|
||||
projects: { requireActiveRealProjectWithIdentity },
|
||||
client,
|
||||
});
|
||||
|
||||
await operations.listProjects();
|
||||
expect(requireActiveRealProjectWithIdentity).not.toHaveBeenCalled();
|
||||
const unconfirmed = await operations.removeProject({ confirmed: false as true });
|
||||
expect(unconfirmed).toMatchObject({ success: false, status: 400, code: 'confirmation_required', data: null });
|
||||
expect(requireActiveRealProjectWithIdentity).not.toHaveBeenCalled();
|
||||
await operations.inspect();
|
||||
expect(requireActiveRealProjectWithIdentity).toHaveBeenCalledTimes(1);
|
||||
expect(client.inspect).toHaveBeenCalledWith(projectId);
|
||||
});
|
||||
});
|
||||
159
tests/unit/data-service-routes.test.ts
Normal file
159
tests/unit/data-service-routes.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
18
tests/unit/data-service-server-registration.test.ts
Normal file
18
tests/unit/data-service-server-registration.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Data Service Host API registration', () => {
|
||||
it('registers before the existing Works catch-all', async () => {
|
||||
const source = await readFile(resolve('electron/api/route-handlers.ts'), 'utf8');
|
||||
expect(source).toMatch(
|
||||
/import\s+\{\s*handleDataServiceRoutes\s*\}\s+from\s+['"]\.\/routes\/data-service['"]/,
|
||||
);
|
||||
const routeList = source.match(/hostApiRouteHandlers[^=]*=\s*\[([\s\S]*?)\];/);
|
||||
expect(routeList?.[1]).toBeDefined();
|
||||
const dataServiceIndex = routeList?.[1].indexOf('handleDataServiceRoutes') ?? -1;
|
||||
const worksIndex = routeList?.[1].indexOf('handleWorksRoutes') ?? -1;
|
||||
expect(dataServiceIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(worksIndex).toBeGreaterThan(dataServiceIndex);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user