mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
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:
parent
8f7207a388
commit
556f9e930d
6 changed files with 163 additions and 327 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
tests/services/service-test-helpers.ts
Normal file
45
tests/services/service-test-helpers.ts
Normal 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']);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue