323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
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('rejects a known error code paired with the wrong HTTP status', async () => {
|
|
const client = new DataServiceCloudClient({
|
|
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(
|
|
jsonResponse({ detail: { code: 'quota_exceeded' } }, { status: 400 }),
|
|
),
|
|
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);
|
|
});
|
|
});
|