refactor: eliminate code duplication flagged by SonarCloud

- Move contentsEqual to src/utils/path.ts (shared by sync-manager + SyncStatusView)
- Replace private isBinary in both files with isBinaryPath from path.ts
- Extract fileItemCallbacks() in SyncStatusView to remove duplicate callback objects
- Create tests/services/service-test-helpers.ts with shared testConnection,
  getRepoGitignores, getFile error handling, getLastRequestCall helpers
- Refactor github/gitlab service tests to use shared helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ClaudiaFang 2026-05-20 09:09:02 +00:00
parent 8f7207a388
commit 556f9e930d
6 changed files with 163 additions and 327 deletions

View file

@ -3,6 +3,7 @@ import { GitServiceInterface } from '../services/git-service-interface';
import { GitLabFilesPushSettings, getServiceName } from '../settings';
import { SyncConflictModal } from '../ui/SyncConflictModal';
import { logger } from '../utils/logger';
import { isBinaryPath, contentsEqual } from '../utils/path';
export class SyncManager {
private readonly app: App;
@ -222,30 +223,11 @@ export class SyncManager {
}
private contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean {
if (typeof a === 'string' && typeof b === 'string') return a === b;
if (typeof a !== typeof b) return false;
const bufA = a as ArrayBuffer;
const bufB = b as ArrayBuffer;
if (bufA.byteLength !== bufB.byteLength) return false;
const viewA = new Uint8Array(bufA);
const viewB = new Uint8Array(bufB);
for (let i = 0; i < viewA.length; i++) {
if (viewA[i] !== viewB[i]) return false;
}
return true;
return contentsEqual(a, b);
}
private isBinary(path: string): boolean {
const ext = path.split('.').pop()?.toLowerCase();
if (!ext) return false;
const BINARY_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar',
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv',
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so'
]);
return BINARY_EXTENSIONS.has(ext);
return isBinaryPath(path);
}
private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false) {

View file

@ -5,7 +5,8 @@ import { ConfirmModal } from './ConfirmModal';
import { logger } from '../utils/logger';
import { type FileStatus, type FilterValue } from './types';
import { renderActionBar } from './components/ActionBar';
import { renderFileItem } from './components/FileListItem';
import { renderFileItem, type FileItemCallbacks } from './components/FileListItem';
import { isBinaryPath, contentsEqual } from '../utils/path';
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
@ -75,17 +76,9 @@ export class SyncStatusView extends ItemView {
.filter(s => this.statusFilter === 'all' || s.status === this.statusFilter);
if (checked.length === 0) return;
const checkedList = container.createDiv({ cls: 'ssv-list-checked' });
const cb = this.fileItemCallbacks();
for (const fs of checked) {
renderFileItem(checkedList, fs, this.selectedFiles.has(fs.path), {
onSelect: (path, selected) => {
if (selected) this.selectedFiles.add(path);
else this.selectedFiles.delete(path);
this.renderView();
},
onPush: (fileStatus) => void this.runSingleFile(fileStatus, 'push'),
onPull: (fileStatus) => void this.runSingleFile(fileStatus, 'pull'),
onDelete: (fileStatus) => void this.handleLocalDelete(fileStatus),
});
renderFileItem(checkedList, fs, this.selectedFiles.has(fs.path), cb);
}
}
@ -191,6 +184,19 @@ export class SyncStatusView extends ItemView {
// ── File list ──────────────────────────────────────────────────
private fileItemCallbacks(): FileItemCallbacks {
return {
onSelect: (path, selected) => {
if (selected) this.selectedFiles.add(path);
else this.selectedFiles.delete(path);
this.renderView();
},
onPush: (fs) => void this.runSingleFile(fs, 'push'),
onPull: (fs) => void this.runSingleFile(fs, 'pull'),
onDelete: (fs) => void this.handleLocalDelete(fs),
};
}
private renderFileList(container: HTMLElement): void {
const all = Array.from(this.fileStatuses.values());
const statuses = this.statusFilter === 'all'
@ -202,17 +208,9 @@ export class SyncStatusView extends ItemView {
return;
}
const cb = this.fileItemCallbacks();
for (const fs of statuses) {
renderFileItem(container, fs, this.selectedFiles.has(fs.path), {
onSelect: (path, selected) => {
if (selected) this.selectedFiles.add(path);
else this.selectedFiles.delete(path);
this.renderView();
},
onPush: (fileStatus) => void this.runSingleFile(fileStatus, 'push'),
onPull: (fileStatus) => void this.runSingleFile(fileStatus, 'pull'),
onDelete: (fileStatus) => void this.handleLocalDelete(fileStatus),
});
renderFileItem(container, fs, this.selectedFiles.has(fs.path), cb);
}
}
@ -487,31 +485,10 @@ export class SyncStatusView extends ItemView {
return this.generateDiff(remoteContent, localContent);
}
private isBinary(path: string): boolean {
const ext = path.split('.').pop()?.toLowerCase();
if (!ext) return false;
const BINARY_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar',
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv',
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so'
]);
return BINARY_EXTENSIONS.has(ext);
}
private isBinary(path: string): boolean { return isBinaryPath(path); }
private contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean {
if (typeof a === 'string' && typeof b === 'string') return a === b;
if (typeof a !== typeof b) return false;
const bufA = a as ArrayBuffer;
const bufB = b as ArrayBuffer;
if (bufA.byteLength !== bufB.byteLength) return false;
const viewA = new Uint8Array(bufA);
const viewB = new Uint8Array(bufB);
for (let i = 0; i < viewA.length; i++) {
if (viewA[i] !== viewB[i]) return false;
}
return true;
return contentsEqual(a, b);
}
private generateDiff(oldContent: string, newContent: string): string {

View file

@ -10,3 +10,17 @@ export function isBinaryPath(path: string): boolean {
if (!ext) return false;
return BINARY_EXTENSIONS.has(ext);
}
export function contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean {
if (typeof a === 'string' && typeof b === 'string') return a === b;
if (typeof a !== typeof b) return false;
const bufA = a as ArrayBuffer;
const bufB = b as ArrayBuffer;
if (bufA.byteLength !== bufB.byteLength) return false;
const viewA = new Uint8Array(bufA);
const viewB = new Uint8Array(bufB);
for (let i = 0; i < viewA.length; i++) {
if (viewA[i] !== viewB[i]) return false;
}
return true;
}

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GitHubService } from '../../src/services/github-service';
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers';
describe('GitHubService', () => {
let service: GitHubService;
@ -16,23 +17,14 @@ describe('GitHubService', () => {
describe('getFile', () => {
it('should fetch and decode file content correctly', async () => {
const mockResponse = {
status: 200,
json: {
content: btoa('hello world'),
sha: 'test-sha'
}
};
vi.mocked(requestUrl).mockResolvedValue(mockResponse as unknown as RequestUrlResponse);
mockRequest({ status: 200, json: { content: btoa('hello world'), sha: 'test-sha' } });
const result = await service.getFile('test.md', 'main');
expect(result.content).toBe('hello world');
expect(result.sha).toBe('test-sha');
});
it('should handle 404 correctly and return empty content', async () => {
vi.mocked(requestUrl).mockResolvedValue({ status: 404 } as unknown as RequestUrlResponse);
mockRequest({ status: 404 });
const result = await service.getFile('missing.md', 'main');
expect(result.content).toBe('');
expect(result.sha).toBe('');
@ -40,40 +32,24 @@ describe('GitHubService', () => {
it('should bypass rootPath when path starts with / (absolute repo path)', async () => {
service.updateConfig(token, owner, repo, 'vault');
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: { content: btoa('root content'), sha: 'root-sha' }
} as unknown as RequestUrlResponse);
mockRequest({ status: 200, json: { content: btoa('root content'), sha: 'root-sha' } });
await service.getFile('/.gitignore', 'main');
const call = vi.mocked(requestUrl).mock.calls[0]?.[0] as RequestUrlParam;
// URL should reference the repo root .gitignore, NOT vault/.gitignore
const call = getLastRequestCall();
expect(call.url).toContain('/contents/.gitignore');
expect(call.url).not.toContain('/contents/vault/.gitignore');
});
it('should not double-prefix when path already starts with rootPath', async () => {
service.updateConfig(token, owner, repo, 'src/content');
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: { content: btoa('hello'), sha: 'sha' }
} as unknown as RequestUrlResponse);
// Path already includes rootPath (e.g. came from listFiles when vault = repo root)
mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } });
await service.getFile('src/content/index.md', 'main');
const call = vi.mocked(requestUrl).mock.calls[0]?.[0] as RequestUrlParam;
const call = getLastRequestCall();
expect(call.url).toContain('/contents/src/content/index.md');
expect(call.url).not.toContain('/contents/src/content/src/content/index.md');
});
it('should return sha correctly', async () => {
const mockResponse = {
status: 200,
json: { content: btoa('test'), sha: 'explicit-sha' }
};
vi.mocked(requestUrl).mockResolvedValue(mockResponse as unknown as RequestUrlResponse);
mockRequest({ status: 200, json: { content: btoa('test'), sha: 'explicit-sha' } });
const result = await service.getFile('test.md', 'main');
expect(result.sha).toBe('explicit-sha');
});
@ -81,103 +57,66 @@ describe('GitHubService', () => {
describe('pushFile', () => {
it('should push new file correctly (no sha provided)', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({
status: 201,
json: { content: { path: 'new.md', sha: 'new-sha' } }
} as unknown as RequestUrlResponse);
vi.mocked(requestUrl).mockResolvedValueOnce({
status: 201,
json: { content: { path: 'new.md', sha: 'new-sha' } }
} as unknown as RequestUrlResponse);
const result = await service.pushFile('new.md', 'new content', 'main', 'create');
expect(result).toEqual({ path: 'new.md', sha: 'new-sha' });
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[calls.length - 1];
if (!lastCallParams) throw new Error('lastCall is undefined');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('PUT');
expect(lastCall.body).not.toContain('"sha":');
const call = getLastRequestCall();
expect(call.method).toBe('PUT');
expect(call.body).not.toContain('"sha":');
});
it('should update existing file correctly (sha provided)', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: { content: { path: 'existing.md', sha: 'updated-sha' } }
} as unknown as RequestUrlResponse);
mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } });
const result = await service.pushFile('existing.md', 'updated content', 'main', 'update', 'old-sha');
expect(result).toEqual({ path: 'existing.md', sha: 'updated-sha' });
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[calls.length - 1];
if (!lastCallParams) throw new Error('lastCall is undefined');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('PUT');
expect(lastCall.body).toContain('"sha":"old-sha"');
const call = getLastRequestCall();
expect(call.method).toBe('PUT');
expect(call.body).toContain('"sha":"old-sha"');
});
});
describe('listFiles', () => {
it('should list blob files from tree API', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: {
tree: [
{ path: 'file1.md', type: 'blob' },
{ path: 'dir/file2.md', type: 'blob' },
{ path: 'subdir', type: 'tree' },
]
}
} as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toEqual(['file1.md', 'dir/file2.md']);
mockRequest({ status: 200, json: { tree: [
{ path: 'file1.md', type: 'blob' },
{ path: 'dir/file2.md', type: 'blob' },
{ path: 'subdir', type: 'tree' },
] } });
expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']);
});
it('should filter by rootPath when set', async () => {
service.updateConfig(token, owner, repo, 'vault');
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: {
tree: [
{ path: 'vault/file1.md', type: 'blob' },
{ path: 'other/file2.md', type: 'blob' },
]
}
} as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toEqual(['vault/file1.md']);
mockRequest({ status: 200, json: { tree: [
{ path: 'vault/file1.md', type: 'blob' },
{ path: 'other/file2.md', type: 'blob' },
] } });
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(token, owner, repo, 'src/content');
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: {
tree: [
{ path: 'src/content/index.md', type: 'blob' },
{ path: 'src/content.config.ts', type: 'blob' },
{ path: 'src/contentful.ts', type: 'blob' },
]
}
} as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toEqual(['src/content/index.md']);
mockRequest({ status: 200, json: { tree: [
{ path: 'src/content/index.md', type: 'blob' },
{ path: 'src/content.config.ts', type: 'blob' },
{ path: 'src/contentful.ts', type: 'blob' },
] } });
expect(await service.listFiles('main')).toEqual(['src/content/index.md']);
});
});
describe('deleteFile', () => {
it('should delete file using its sha', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({
status: 200,
json: { content: btoa('content'), sha: 'file-sha' }
} as unknown as RequestUrlResponse)
.mockResolvedValueOnce({
status: 200,
json: {}
} as unknown as RequestUrlResponse);
.mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse);
await service.deleteFile('test.md', 'main', 'delete test.md');
@ -190,50 +129,14 @@ describe('GitHubService', () => {
});
describe('testConnection', () => {
it('should return true on successful connection', async () => {
vi.mocked(requestUrl).mockResolvedValue({ status: 200, json: {} } as unknown as RequestUrlResponse);
const result = await service.testConnection();
expect(result).toBe(true);
});
it('should return false on failed connection', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 401,
json: { message: 'Unauthorized' },
text: 'Unauthorized'
} as unknown as RequestUrlResponse);
const result = await service.testConnection();
expect(result).toBe(false);
});
sharedTestConnection(() => service);
});
describe('getRepoGitignores', () => {
it('should return only .gitignore paths from file list', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: {
tree: [
{ path: '.gitignore', type: 'blob' },
{ path: 'src/main.ts', type: 'blob' },
{ path: 'sub/.gitignore', type: 'blob' },
]
}
} as unknown as RequestUrlResponse);
const result = await service.getRepoGitignores('main');
expect(result).toEqual(['.gitignore', 'sub/.gitignore']);
});
sharedGetRepoGitignores(() => service, 'tree');
});
describe('getFile error handling', () => {
it('should rethrow non-404 errors', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 500,
json: { message: 'Internal Server Error' },
text: 'Internal Server Error'
} as unknown as RequestUrlResponse);
await expect(service.getFile('test.md', 'main')).rejects.toThrow('500');
});
sharedGetFileErrorHandling(() => service);
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GitLabService } from '../../src/services/gitlab-service';
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers';
describe('GitLabService', () => {
let service: GitLabService;
@ -16,44 +16,24 @@ describe('GitLabService', () => {
describe('getFile', () => {
it('should fetch and decode file content correctly', async () => {
const mockResponse = {
status: 200,
json: {
content: btoa('hello world'),
last_commit_id: 'test-commit-id'
}
} as unknown as RequestUrlResponse;
vi.mocked(requestUrl).mockResolvedValue(mockResponse);
mockRequest({ status: 200, json: { content: btoa('hello world'), last_commit_id: 'test-commit-id' } });
const result = await service.getFile('test.md', 'main');
expect(result.content).toBe('hello world');
expect(result.sha).toBe('test-commit-id');
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[0];
if (!lastCallParams) throw new Error('requestUrl was not called');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('GET');
expect(lastCall.headers).toMatchObject({ 'PRIVATE-TOKEN': token });
const call = getLastRequestCall();
expect(call.method).toBe('GET');
expect(call.headers).toMatchObject({ 'PRIVATE-TOKEN': token });
});
it('should handle 404 correctly in getFile and return empty content', async () => {
vi.mocked(requestUrl).mockResolvedValue({ status: 404 } as unknown as RequestUrlResponse);
mockRequest({ status: 404 });
const result = await service.getFile('missing.md', 'main');
expect(result.content).toBe('');
expect(result.sha).toBe('');
});
it('should return last_commit_id as sha', async () => {
const mockResponse = {
status: 200,
json: {
content: btoa('test content'),
last_commit_id: 'test-last-commit-id'
}
} as unknown as RequestUrlResponse;
vi.mocked(requestUrl).mockResolvedValue(mockResponse);
mockRequest({ status: 200, json: { content: btoa('test content'), last_commit_id: 'test-last-commit-id' } });
const result = await service.getFile('test.md', 'main');
expect(result.sha).toBe('test-last-commit-id');
});
@ -61,138 +41,73 @@ describe('GitLabService', () => {
describe('pushFile', () => {
it('should push file content correctly (POST for new file)', async () => {
vi.mocked(requestUrl).mockResolvedValue({ status: 201, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse);
mockRequest({ status: 201, json: { file_path: 'test.md' } });
const result = await service.pushFile('test.md', 'new content', 'main', 'initial commit');
expect(result).toEqual({ path: 'test.md' });
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[calls.length - 1];
if (!lastCallParams) throw new Error('requestUrl was not called');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('POST');
expect(lastCall.body).toContain(btoa('new content'));
const call = getLastRequestCall();
expect(call.method).toBe('POST');
expect(call.body).toContain(btoa('new content'));
});
it('should push file content correctly (PUT for existing file)', async () => {
vi.mocked(requestUrl).mockResolvedValue({ status: 200, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse);
mockRequest({ status: 200, json: { file_path: 'test.md' } });
const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha');
expect(result).toEqual({ path: 'test.md' });
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[calls.length - 1];
if (!lastCallParams) throw new Error('requestUrl was not called');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('PUT');
expect(lastCall.body).toContain(btoa('updated content'));
const call = getLastRequestCall();
expect(call.method).toBe('PUT');
expect(call.body).toContain(btoa('updated content'));
});
});
describe('listFiles', () => {
it('should list blob files from tree API', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: [
{ path: 'file1.md', type: 'blob' },
{ path: 'dir/file2.md', type: 'blob' },
{ path: 'subdir', type: 'tree' },
]
} as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toEqual(['file1.md', 'dir/file2.md']);
mockRequest({ status: 200, json: [
{ path: 'file1.md', type: 'blob' },
{ path: 'dir/file2.md', type: 'blob' },
{ path: 'subdir', type: 'tree' },
] });
expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']);
});
it('should filter by rootPath when set', async () => {
service.updateConfig(baseUrl, token, projectId, 'vault');
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: [
{ path: 'vault/file1.md', type: 'blob' },
{ path: 'other/file2.md', type: 'blob' },
]
} as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toEqual(['vault/file1.md']);
mockRequest({ status: 200, json: [
{ path: 'vault/file1.md', type: 'blob' },
{ path: 'other/file2.md', type: 'blob' },
] });
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(baseUrl, token, projectId, 'src/content');
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: [
{ path: 'src/content/index.md', type: 'blob' },
{ path: 'src/content.config.ts', type: 'blob' },
{ path: 'src/contentful.ts', type: 'blob' },
]
} as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toEqual(['src/content/index.md']);
mockRequest({ status: 200, json: [
{ path: 'src/content/index.md', type: 'blob' },
{ path: 'src/content.config.ts', type: 'blob' },
{ path: 'src/contentful.ts', type: 'blob' },
] });
expect(await service.listFiles('main')).toEqual(['src/content/index.md']);
});
});
describe('deleteFile', () => {
it('should delete file with commit message', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: {}
} as unknown as RequestUrlResponse);
mockRequest({ status: 200, json: {} });
await service.deleteFile('test.md', 'main', 'delete test.md');
const calls = vi.mocked(requestUrl).mock.calls;
const deleteCall = calls[0]?.[0] as RequestUrlParam;
expect(deleteCall.method).toBe('DELETE');
expect(deleteCall.body).toContain('"commit_message":"delete test.md"');
const call = getLastRequestCall();
expect(call.method).toBe('DELETE');
expect(call.body).toContain('"commit_message":"delete test.md"');
});
});
describe('testConnection', () => {
it('should return true on successful connection', async () => {
vi.mocked(requestUrl).mockResolvedValue({ status: 200, json: {} } as unknown as RequestUrlResponse);
const result = await service.testConnection();
expect(result).toBe(true);
});
it('should return false on failed connection', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 401,
json: { message: 'Unauthorized' },
text: 'Unauthorized'
} as unknown as RequestUrlResponse);
const result = await service.testConnection();
expect(result).toBe(false);
});
sharedTestConnection(() => service);
});
describe('getRepoGitignores', () => {
it('should return only .gitignore paths from file list', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
json: [
{ path: '.gitignore', type: 'blob' },
{ path: 'src/main.ts', type: 'blob' },
{ path: 'sub/.gitignore', type: 'blob' },
]
} as unknown as RequestUrlResponse);
const result = await service.getRepoGitignores('main');
expect(result).toEqual(['.gitignore', 'sub/.gitignore']);
});
sharedGetRepoGitignores(() => service, 'json');
});
describe('getFile error handling', () => {
it('should rethrow non-404 errors', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 500,
json: { message: 'Internal Server Error' },
text: 'Internal Server Error'
} as unknown as RequestUrlResponse);
await expect(service.getFile('test.md', 'main')).rejects.toThrow('500');
});
sharedGetFileErrorHandling(() => service);
});
});

View file

@ -0,0 +1,45 @@
import { it, expect, vi } from 'vitest';
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
import type { GitServiceInterface } from '../../src/services/git-service-interface';
export function getLastRequestCall(): RequestUrlParam {
const calls = vi.mocked(requestUrl).mock.calls;
const last = calls[calls.length - 1];
if (!last) throw new Error('requestUrl was not called');
return last[0] as RequestUrlParam;
}
export function mockRequest(response: Partial<RequestUrlResponse>): void {
vi.mocked(requestUrl).mockResolvedValue(response as RequestUrlResponse);
}
export function sharedTestConnection(getService: () => GitServiceInterface): void {
it('should return true on successful connection', async () => {
mockRequest({ status: 200, json: {} });
expect(await getService().testConnection()).toBe(true);
});
it('should return false on failed connection', async () => {
mockRequest({ status: 401, json: { message: 'Unauthorized' }, text: 'Unauthorized' });
expect(await getService().testConnection()).toBe(false);
});
}
export function sharedGetFileErrorHandling(getService: () => GitServiceInterface): void {
it('should rethrow non-404 errors', async () => {
mockRequest({ status: 500, json: { message: 'Internal Server Error' }, text: 'Internal Server Error' });
await expect(getService().getFile('test.md', 'main')).rejects.toThrow('500');
});
}
export function sharedGetRepoGitignores(getService: () => GitServiceInterface, treeKey: 'tree' | 'json'): void {
it('should return only .gitignore paths from file list', async () => {
const items = [
{ path: '.gitignore', type: 'blob' },
{ path: 'src/main.ts', type: 'blob' },
{ path: 'sub/.gitignore', type: 'blob' },
];
mockRequest({ status: 200, json: treeKey === 'tree' ? { tree: items } : items });
expect(await getService().getRepoGitignores('main')).toEqual(['.gitignore', 'sub/.gitignore']);
});
}