diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index c85fa34..a2feb60 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -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) { diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index da1e66e..110354b 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -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 { diff --git a/src/utils/path.ts b/src/utils/path.ts index b3d3146..2b78dc7 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -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; +} diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 462fee0..1bad1c1 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -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); }); }); diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index a1fd6a1..8c6fa56 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -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); }); }); diff --git a/tests/services/service-test-helpers.ts b/tests/services/service-test-helpers.ts new file mode 100644 index 0000000..5570f4a --- /dev/null +++ b/tests/services/service-test-helpers.ts @@ -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): 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']); + }); +}