mirror of
https://github.com/zoorpha/rustshare-obsidian-plugin.git
synced 2026-07-22 07:48:14 +00:00
420 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|