zoorpha_rustshare-obsidian-.../tests/api-contract.test.ts
2026-06-22 21:52:32 +02:00

420 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { requestUrl } from 'obsidian';
import { RustShareAPI, ConflictError } from '../src/api';
interface MockUrlResponse {
status: number;
headers: Record<string, string>;
text: string;
json: unknown;
arrayBuffer: ArrayBuffer;
}
function createMockUrlResponse(options: {
status: number;
jsonData?: unknown;
text?: string;
arrayBuffer?: ArrayBuffer;
contentType?: string;
}): MockUrlResponse {
const {
status,
jsonData,
text,
arrayBuffer,
contentType = 'application/json',
} = options;
return {
status,
headers: { 'content-type': contentType },
text: text ?? (jsonData ? JSON.stringify(jsonData) : ''),
json: jsonData,
arrayBuffer: arrayBuffer ?? new ArrayBuffer(0),
};
}
describe('API contract verification', () => {
let api: RustShareAPI;
beforeEach(() => {
api = new RustShareAPI('https://api.rustshare.test', 'test-token');
(requestUrl as ReturnType<typeof vi.fn>).mockReset();
});
describe('ConflictError parsing', () => {
it('parses 409 response into ConflictError with correct fields', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({
status: 409,
jsonData: {
error: 'Conflict',
client_rev: 5,
current_rev: 7,
server_sha256: 'abc123',
},
})
);
try {
await api.uploadFile(
'vault-1',
'notes/test.md',
new ArrayBuffer(0),
'hash',
1,
'device-1'
);
expect.fail('expected throw');
} catch (e: any) {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe('Conflict detected');
expect(e.error).toBe('conflict');
expect(e.path).toBe('/vaults/vault-1/files/notes/test.md');
expect(e.client_rev).toBe(5);
expect(e.current_rev).toBe(7);
expect(e.server_sha256).toBe('abc123');
expect(e.resolution).toBe('create_conflict_copy');
}
});
it('preserves all fields from a full 409 response', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({
status: 409,
jsonData: {
error: 'Conflict',
message: 'File was modified',
path: 'notes/x.md',
client_rev: 5,
current_rev: 7,
server_sha256: 'abc123',
resolution: 'download_server_version',
},
})
);
try {
await api.uploadFile(
'vault-1',
'notes/x.md',
new ArrayBuffer(0),
'hash',
1,
'device-1'
);
expect.fail('expected throw');
} catch (e: any) {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe('File was modified');
expect(e.error).toBe('conflict');
expect(e.path).toBe('notes/x.md');
expect(e.client_rev).toBe(5);
expect(e.current_rev).toBe(7);
expect(e.server_sha256).toBe('abc123');
expect(e.resolution).toBe('download_server_version');
}
});
});
describe('Header names', () => {
it('uploadFile sends exact backend header names', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.uploadFile(
'vault-1',
'notes/test.md',
new ArrayBuffer(4),
'sha256-abc',
3,
'device-123'
);
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
const headers = call.headers as Record<string, string>;
expect(headers).toHaveProperty('X-RustShare-SHA256', 'sha256-abc');
expect(headers).toHaveProperty('X-RustShare-Base-Server-Rev', '3');
expect(headers).toHaveProperty('X-RustShare-Device-ID', 'device-123');
expect(headers).toHaveProperty('Content-Type', 'text/markdown');
});
it('deleteFile sends exact backend header names', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.deleteFile('vault-1', 'notes/test.md', 2, 'device-123');
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
const headers = call.headers as Record<string, string>;
expect(headers).toHaveProperty('X-RustShare-Base-Server-Rev', '2');
expect(headers).toHaveProperty('X-RustShare-Device-ID', 'device-123');
});
it('includes Authorization and Content-Type for JSON bodies', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 200, jsonData: { id: 'd1' } })
);
const result = await api.registerDevice('my-device', 'obsidian');
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
const headers = call.headers as Record<string, string>;
expect(headers).toHaveProperty('Authorization', 'Bearer test-token');
expect(headers).toHaveProperty('Content-Type', 'application/json');
expect(result.id).toBe('d1');
});
it('includes CSRF header on mutating methods', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.uploadFile('vault-1', 'test.md', new ArrayBuffer(0), 'hash', 1, 'device-1');
const call1 = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call1.headers).toHaveProperty('X-Rustshare-Csrf', '1');
(requestUrl as ReturnType<typeof vi.fn>).mockReset();
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 200, jsonData: { id: 'd1' } })
);
await api.registerDevice('my-device', 'obsidian');
const call2 = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call2.headers).toHaveProperty('X-Rustshare-Csrf', '1');
(requestUrl as ReturnType<typeof vi.fn>).mockReset();
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.deleteFile('vault-1', 'test.md', 1, 'device-1');
const call3 = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call3.headers).toHaveProperty('X-Rustshare-Csrf', '1');
(requestUrl as ReturnType<typeof vi.fn>).mockReset();
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.renameFile('vault-1', { old_path: 'a.md', new_path: 'b.md', base_server_rev: 1, device_id: 'device-1' });
const call4 = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call4.headers).toHaveProperty('X-Rustshare-Csrf', '1');
});
});
describe('URL path construction', () => {
it('builds vault-sync file URLs correctly', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.uploadFile(
'vault-1',
'notes/hello.md',
new ArrayBuffer(0),
'hash',
1,
'device-1'
);
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.url).toBe(
'https://api.rustshare.test/api/vault-sync/v1/vaults/vault-1/files/notes/hello.md'
);
});
it('builds manifest URL correctly', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({
status: 200,
jsonData: { vault_id: 'v1', adapter: 'ObsidianVault', server_rev: 1, generated_at: new Date().toISOString(), files: [] },
})
);
await api.getManifest('vault-1');
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.url).toBe(
'https://api.rustshare.test/api/vault-sync/v1/vaults/vault-1/manifest'
);
});
it('builds device register URL correctly', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 200, jsonData: { id: 'd1' } })
);
await api.registerDevice('my-device', 'obsidian');
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.url).toBe(
'https://api.rustshare.test/api/vault-sync/v1/devices/register'
);
});
it('builds device pairing request URL under /api/v1', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({
status: 200,
jsonData: {
user_code: 'ABCD1234',
device_code: 'device-code',
expires_in: 600,
verification_uri: 'https://api.rustshare.test/device',
verification_uri_complete: 'https://api.rustshare.test/device?code=ABCD1234',
},
})
);
await api.requestDevicePairing();
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.url).toBe('https://api.rustshare.test/api/v1/auth/device/request');
});
it('builds device pairing poll URL under /api/v1', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 200, jsonData: { status: 'pending' } })
);
await api.pollDevicePairing('device-code');
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.url).toBe('https://api.rustshare.test/api/v1/auth/device/poll');
});
it('URL-encodes vault IDs and file paths', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.uploadFile(
'vault test',
'notes/hello world.md',
new ArrayBuffer(0),
'hash',
1,
'device-1'
);
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.url).toBe(
'https://api.rustshare.test/api/vault-sync/v1/vaults/vault%20test/files/notes/hello%20world.md'
);
});
it('URL-encodes special characters ? # & in file paths', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.uploadFile('vault-1', 'notes/what?.md', new ArrayBuffer(0), 'hash', 1, 'device-1');
expect((requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0].url).toBe(
'https://api.rustshare.test/api/vault-sync/v1/vaults/vault-1/files/notes/what%3F.md'
);
(requestUrl as ReturnType<typeof vi.fn>).mockReset();
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.uploadFile('vault-1', 'notes/hash#1.md', new ArrayBuffer(0), 'hash', 1, 'device-1');
expect((requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0].url).toBe(
'https://api.rustshare.test/api/vault-sync/v1/vaults/vault-1/files/notes/hash%231.md'
);
(requestUrl as ReturnType<typeof vi.fn>).mockReset();
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await api.uploadFile('vault-1', 'notes/a&b.md', new ArrayBuffer(0), 'hash', 1, 'device-1');
expect((requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0].url).toBe(
'https://api.rustshare.test/api/vault-sync/v1/vaults/vault-1/files/notes/a%26b.md'
);
});
});
describe('uploadFile return value', () => {
it('returns server_rev from backend', async () => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 200, jsonData: { server_rev: 3 } })
);
const result = await api.uploadFile(
'vault-1',
'notes/test.md',
new ArrayBuffer(4),
'sha256-abc',
2,
'device-123'
);
expect(result).toEqual({ server_rev: 3 });
});
});
describe('HTTP method mapping', () => {
it.each([
{
method: 'uploadFile',
args: [
'vault-1',
'test.md',
new ArrayBuffer(0),
'hash',
1,
'device-1',
],
expected: 'PUT',
},
{
method: 'downloadFile',
args: ['vault-1', 'test.md'],
expected: 'GET',
},
{
method: 'deleteFile',
args: ['vault-1', 'test.md', 1, 'device-1'],
expected: 'DELETE',
},
{
method: 'renameFile',
args: [
'vault-1',
{
old_path: 'a.md',
new_path: 'b.md',
base_server_rev: 1,
device_id: 'device-1',
},
],
expected: 'POST',
},
{
method: 'getManifest',
args: ['vault-1'],
expected: 'GET',
},
{
method: 'registerDevice',
args: ['my-device', 'obsidian'],
expected: 'POST',
},
{
method: 'revokeDevice',
args: ['device-1'],
expected: 'DELETE',
},
])('$method uses $expected', async ({ method, args, expected }) => {
(requestUrl as ReturnType<typeof vi.fn>).mockResolvedValue(
createMockUrlResponse({ status: 204 })
);
await (api as any)[method](...args);
const call = (requestUrl as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(call.method).toBe(expected);
});
});
});