diff --git a/docs/test-coverage.md b/docs/test-coverage.md new file mode 100644 index 0000000..1bbe276 --- /dev/null +++ b/docs/test-coverage.md @@ -0,0 +1,227 @@ +# Test Coverage + +All tests are in `tests/` and run with `npm run test` (Vitest). + +--- + +## utils/path — `tests/utils/path.test.ts` + +### `isBinaryPath()` +| Group | Cases | +|---|---| +| Image formats | png, jpg, gif, bmp, webp (legacy); heic, heif, avif, tiff, tif, jxl (modern) | +| Audio formats | mp3, wav, ogg, flac, aac, m4a, opus, aiff | +| Video formats | mp4, webm, mov, mkv, avi, wmv, flv, m4v | +| Archive formats | zip, tar, gz, bz2, xz, zst, 7z, rar | +| Font formats | ttf, otf, woff, woff2 | +| Database formats | sqlite, db | +| Text files | md, json, ts, yml, txt → returns false | +| Hidden file paths | `.gitignore`, `.env` → false; `.claude/settings.json` → false; `.claude/photo.png` → true | +| Case insensitivity | `.PNG`, `.JPG`, `.FLAC` | +| BINARY_EXTENSIONS completeness | Set membership checks for major categories | + +### `contentsEqual()` +| Group | Cases | +|---|---| +| String | identical, different, empty, case-sensitive | +| ArrayBuffer | identical, different bytes, different lengths, empty buffers | +| Mixed types | string vs ArrayBuffer → false | + +--- + +## logic/SyncManager — `tests/logic/sync-manager.test.ts` + +| Case | +|---| +| Push file content correctly | +| Detect conflict when remote SHA differs from last synced SHA | +| Resolve conflict by choosing local | +| Resolve conflict by choosing remote | +| Update metadata when file is already in sync (contentsEqual) | +| Update metadata after successful push | +| Pull and modify file content, update metadata | +| Handle file not existing in vault | +| Add new file to repo when exists locally but not on remote | +| **Renames:** detect and handle file rename | +| **Error handling:** push errors, rename errors | +| **pullFile:** file not in remote, pull new file, pull errors | + +--- + +## logic/SyncManager Batch — `tests/logic/sync-manager-batch.test.ts` + +| Case | +|---| +| Push multiple files (TFile and string paths) | +| Handle failures during batch push | +| Pull multiple files (TFile and string paths) | +| Handle missing remote files during batch pull | +| onProgress callback called for each file | +| Detect and handle rename during batch push | + +--- + +## logic/SyncManager Path Mapping — `tests/logic/sync-manager-mapping.test.ts` + +| Case | +|---| +| Strip vaultFolder prefix when pushing | +| Re-add vaultFolder prefix when pulling | +| Handle root-level files with no vaultFolder | + +--- + +## logic/SyncManager Binary Files — `tests/logic/sync-manager-binary.test.ts` + +### `pushFile` with binary path (string) +| Case | +|---| +| Reads via `adapter.readBinary` for binary extensions | +| Skips push when binary content is already in sync | +| Updates metadata when binary is already in sync | + +### `pullFile` with binary content +| Case | +|---| +| Writes via `adapter.writeBinary` when remote content is ArrayBuffer | +| Creates parent directory before writing binary | +| Skips pull when binary content is already in sync | +| Updates metadata after pulling binary file | + +--- + +## logic/SyncManager Hidden Files — `tests/logic/sync-manager-hidden.test.ts` + +### `pullFile` with hidden paths +| Case | +|---| +| Creates single hidden parent directory (`.claude/`) on pull | +| Creates all nested hidden parent directories (`.claude/memory/`) on pull | +| Does not throw if hidden directory already exists (mkdir error is swallowed) | +| Updates syncMetadata after pulling hidden file | + +### `pushFile` with hidden paths +| Case | +|---| +| Pushes hidden file content via string path | +| Skips push when hidden file is already in sync (same content) | +| Does not push when hidden file is missing from vault | + +--- + +## logic/GitignoreManager — `tests/logic/gitignore-manager.test.ts` + +### `loadGitignores()` +| Case | +|---| +| Load root .gitignore from local if it exists | +| Load gitignores from remote as fallback | +| Fall back to `[".gitignore"]` when getRepoGitignores throws | +| Fall back to remote when local .gitignore read throws | +| Handle subdirectory .gitignores correctly | +| Pick up local-only subdirectory .gitignore not yet on remote | + +### `isIgnored()` with rootPath +| Case | +|---| +| Correctly resolve paths relative to git root | + +### Complex patterns +| Case | +|---| +| Negative patterns (`!important.log`) | +| Directory-only patterns (`build/`) | +| Deep wildcards (`**/temp/*`) | + +### Hidden file and directory filtering +| Case | +|---| +| Ignore hidden directory when listed in .gitignore (`.obsidian/`, `.trash/`) | +| Pass through hidden directory absent from .gitignore (`.claude/`) | +| Ignore `.claude/` when explicitly added to .gitignore | +| Pass normal files when only hidden dirs are ignored | +| Find `.gitignore` inside a hidden directory via scanDir | + +--- + +## services/GitServiceBase — `tests/services/git-service-base.test.ts` + +| Case | +|---| +| No double-slash when rootPath already ends with `/` | +| Wrap non-Error throws in a new Error | +| Correctly encode and decode UTF-8 content | + +--- + +## services/GitHubService — `tests/services/github-service.test.ts` + +| Group | Cases | +|---|---| +| getFile | fetch and decode content, 404 handling, bypass rootPath with `/` prefix, no double-prefix, return sha | +| pushFile | new file (no sha), update existing file (with sha) | +| listFiles | list blob files, filter by rootPath, no sibling path collision, truncated result warning | +| deleteFile | delete using file sha | + +--- + +## services/GitLabService — `tests/services/gitlab-service.test.ts` + +| Group | Cases | +|---|---| +| getFile | fetch and decode content, 404 handling, return last_commit_id as sha | +| pushFile | POST for new file, PUT for existing file | +| listFiles | list blob files, filter by rootPath, no sibling path collision, pagination boundary (100 items), multi-page (200 items), stop on partial page | +| deleteFile | delete with commit message | + +--- + +## ui/ActionBar — `tests/ui/ActionBar.test.ts` + +| Group | Cases | +|---|---| +| No files | always renders refresh button; no push/pull/delete; no select-all | +| Has files | renders push, pull, delete, select-all; disabled states when count is 0; enabled when count > 0 | +| Interactions | onRefresh, onPush, onPull, onDelete, onSelectAll(true/false) | + +--- + +## ui/DiffPanel — `tests/ui/DiffPanel.test.ts` + +| Case | +|---| +| Returns element with `ssv-diff` class | +| Renders Remote and Local column headers | +| Renders unchanged lines | +| Renders added line | +| Renders removed line | +| Renders replacement (removed + added) | +| Renders unchanged lines for identical content | + +--- + +## ui/FileListItem — `tests/ui/FileListItem.test.ts` + +| Group | Cases | +|---|---| +| Status CSS | distinct class for each status | +| Rendering | file path, status badge, checkbox (selected/unselected) | +| Interactions | onSelect(path, true/false) | +| Synced status | no action buttons | +| Remote-only status | no action buttons | +| Modified status | push + pull buttons; onPush/onPull callbacks; diff toggle; diff panel visibility toggle; diff button label toggle | +| Local-only status | push + delete buttons; no pull; onDelete callback | +| Missing status | pull button; no push | + +--- + +## utils/diff — `tests/utils/diff.test.ts` + +| Group | Cases | +|---|---| +| Identical content | single-line, multi-line, empty strings | +| CRLF normalisation | CRLF = LF, CR-only | +| Additions only | single added line, multiple added lines, single-line change | +| Removals only | single removed line, multiple removed lines | +| Mixed changes | paired removed+added, unchanged lines between changes | +| Line numbers | correct numbering | diff --git a/tests/logic/gitignore-manager.test.ts b/tests/logic/gitignore-manager.test.ts index c75158e..81aeb0e 100644 --- a/tests/logic/gitignore-manager.test.ts +++ b/tests/logic/gitignore-manager.test.ts @@ -223,4 +223,63 @@ describe('GitignoreManager', () => { expect(manager.isIgnored('a/temp/foo')).toBe(true); }); }); + + describe('hidden file and directory filtering', () => { + beforeEach(async () => { + vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']); + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.exists).mockResolvedValue(true); + }); + + it('should ignore hidden directory when listed in .gitignore', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('.private/\n.trash/'); + await manager.loadGitignores(); + + expect(manager.isIgnored('.private/secrets.md')).toBe(true); + expect(manager.isIgnored('.trash/note.md')).toBe(true); + }); + + it('should not ignore hidden directory absent from .gitignore', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('node_modules/\n*.log'); + await manager.loadGitignores(); + + expect(manager.isIgnored('.claude/settings.json')).toBe(false); + expect(manager.isIgnored('.claude/CLAUDE.md')).toBe(false); + }); + + it('should ignore .claude/ when explicitly added to .gitignore', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('.claude/'); + await manager.loadGitignores(); + + expect(manager.isIgnored('.claude/settings.json')).toBe(true); + expect(manager.isIgnored('.claude/memory/user.md')).toBe(true); + }); + + it('should still pass normal files when only hidden dirs are ignored', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.read).mockResolvedValue('.private/'); + await manager.loadGitignores(); + + expect(manager.isIgnored('notes/my-note.md')).toBe(false); + expect(manager.isIgnored('projects/work.md')).toBe(false); + }); + + it('should find .gitignore inside a hidden directory via scanDir', async () => { + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.list) + .mockResolvedValueOnce({ files: ['.gitignore'], folders: ['.claude'] }) + .mockResolvedValueOnce({ files: ['.claude/.gitignore'], folders: [] }); + vi.mocked(adapter.exists).mockResolvedValue(true); + vi.mocked(adapter.read).mockResolvedValue('*.secret'); + vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']); + + await manager.loadGitignores(); + + expect(adapter.list).toHaveBeenCalledWith(''); + expect(adapter.list).toHaveBeenCalledWith('.claude'); + }); + }); }); diff --git a/tests/logic/sync-manager-binary.test.ts b/tests/logic/sync-manager-binary.test.ts new file mode 100644 index 0000000..fbee3d0 --- /dev/null +++ b/tests/logic/sync-manager-binary.test.ts @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; +import { SyncManager } from '../../src/logic/sync-manager'; +import { App, DataAdapter } from 'obsidian'; +import { GitLabFilesPushSettings } from '../../src/settings'; +import { GitServiceInterface } from '../../src/services/git-service-interface'; + +vi.mock('obsidian'); + +describe('SyncManager – binary file handling', () => { + let manager: SyncManager; + let mockApp: Mocked; + let mockGitService: Mocked; + let mockAdapter: Mocked; + let mockSettings: GitLabFilesPushSettings; + + const makeBuf = (bytes: number[]) => new Uint8Array(bytes).buffer; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAdapter = { + exists: vi.fn(), + read: vi.fn(), + write: vi.fn(), + readBinary: vi.fn(), + writeBinary: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + mockApp = { + vault: { + read: vi.fn(), + readBinary: vi.fn(), + modify: vi.fn(), + modifyBinary: vi.fn(), + getFileByPath: vi.fn().mockReturnValue(null), + adapter: mockAdapter, + }, + } as unknown as Mocked; + + mockGitService = { + pushFile: vi.fn(), + getFile: vi.fn(), + testConnection: vi.fn(), + listFiles: vi.fn(), + deleteFile: vi.fn(), + getRepoGitignores: vi.fn(), + updateConfig: vi.fn(), + } as unknown as Mocked; + + mockSettings = { + serviceType: 'github', + githubToken: 'token', + githubOwner: 'owner', + githubRepo: 'repo', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings; + + manager = new SyncManager(mockApp, mockGitService, mockSettings); + // @ts-ignore + manager.saveSettings = vi.fn().mockResolvedValue(undefined); + }); + + describe('pushFile with binary path (string)', () => { + it('reads via adapter.readBinary for binary extensions', async () => { + const buf = makeBuf([137, 80, 78, 71]); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: '', content: '' }); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: 'photo.png', sha: 'new-sha' }); + + await manager.pushFile('photo.png'); + + expect(mockAdapter.readBinary).toHaveBeenCalledWith('photo.png'); + expect(mockAdapter.read).not.toHaveBeenCalled(); + expect(mockGitService.pushFile).toHaveBeenCalledWith( + 'photo.png', buf, 'main', expect.any(String), '' + ); + }); + + it('skips push when binary content is already in sync', async () => { + const buf = makeBuf([1, 2, 3, 4]); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content: buf }); + + await manager.pushFile('photo.png'); + + expect(mockGitService.pushFile).not.toHaveBeenCalled(); + }); + + it('updates metadata when binary is already in sync', async () => { + const buf = makeBuf([1, 2, 3]); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content: buf }); + + await manager.pushFile('photo.png'); + + expect(mockSettings.syncMetadata['photo.png']).toMatchObject({ + lastSyncedSha: 'existing-sha', + }); + }); + }); + + describe('pullFile with binary content', () => { + it('writes via adapter.writeBinary when remote content is ArrayBuffer', async () => { + const buf = makeBuf([137, 80, 78, 71]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('photo.png'); + + expect(mockAdapter.writeBinary).toHaveBeenCalledWith('photo.png', buf); + expect(mockAdapter.write).not.toHaveBeenCalled(); + }); + + it('creates parent directory before writing binary', async () => { + const buf = makeBuf([255, 216, 255]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('attachments/photo.jpg'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('attachments'); + expect(mockAdapter.writeBinary).toHaveBeenCalledWith('attachments/photo.jpg', buf); + }); + + it('skips pull when binary content is already in sync', async () => { + const buf = makeBuf([1, 2, 3]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf); + mockSettings.syncMetadata['photo.png'] = { lastSyncedSha: 'bin-sha', lastSyncedAt: 0, lastKnownPath: 'photo.png' }; + + await manager.pullFile('photo.png'); + + expect(mockAdapter.writeBinary).not.toHaveBeenCalled(); + }); + + it('updates metadata after pulling binary file', async () => { + const buf = makeBuf([0, 1, 2]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('photo.png'); + + expect(mockSettings.syncMetadata['photo.png']).toMatchObject({ + lastSyncedSha: 'bin-sha', + }); + }); + }); +}); diff --git a/tests/logic/sync-manager-hidden.test.ts b/tests/logic/sync-manager-hidden.test.ts new file mode 100644 index 0000000..b4d6eb8 --- /dev/null +++ b/tests/logic/sync-manager-hidden.test.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; +import { SyncManager } from '../../src/logic/sync-manager'; +import { App, DataAdapter } from 'obsidian'; +import { GitLabFilesPushSettings } from '../../src/settings'; +import { GitServiceInterface } from '../../src/services/git-service-interface'; + +vi.mock('obsidian'); + +describe('SyncManager – hidden file support', () => { + let manager: SyncManager; + let mockApp: Mocked; + let mockGitService: Mocked; + let mockAdapter: Mocked; + let mockSettings: GitLabFilesPushSettings; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAdapter = { + exists: vi.fn(), + read: vi.fn(), + write: vi.fn(), + writeBinary: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + + mockApp = { + vault: { + read: vi.fn(), + modify: vi.fn(), + getFileByPath: vi.fn().mockReturnValue(null), + adapter: mockAdapter, + }, + } as unknown as Mocked; + + mockGitService = { + pushFile: vi.fn(), + getFile: vi.fn(), + testConnection: vi.fn(), + listFiles: vi.fn(), + deleteFile: vi.fn(), + getRepoGitignores: vi.fn(), + updateConfig: vi.fn(), + } as unknown as Mocked; + + mockSettings = { + serviceType: 'github', + githubToken: 'token', + githubOwner: 'owner', + githubRepo: 'repo', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings; + + manager = new SyncManager(mockApp, mockGitService, mockSettings); + // @ts-ignore - accessing private for test + manager.saveSettings = vi.fn().mockResolvedValue(undefined); + }); + + describe('pullFile with hidden paths', () => { + it('creates single hidden parent directory on pull', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'abc123', + content: '{"key":"value"}', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('.claude/settings.json'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude'); + expect(mockAdapter.write).toHaveBeenCalledWith('.claude/settings.json', '{"key":"value"}'); + }); + + it('creates all nested hidden parent directories on pull', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'def456', + content: 'nested content', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('.claude/memory/user.md'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude/memory'); + expect(mockAdapter.write).toHaveBeenCalledWith('.claude/memory/user.md', 'nested content'); + }); + + it('does not fail if hidden directory already exists (mkdir throws)', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'abc123', + content: 'content', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + vi.mocked(mockAdapter.mkdir).mockRejectedValue(new Error('already exists')); + + await expect(manager.pullFile('.claude/settings.json')).resolves.not.toThrow(); + expect(mockAdapter.write).toHaveBeenCalledWith('.claude/settings.json', 'content'); + }); + + it('updates metadata after pulling hidden file', async () => { + vi.mocked(mockGitService.getFile).mockResolvedValue({ + sha: 'sha-hidden', + content: 'file content', + }); + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pullFile('.claude/CLAUDE.md'); + + expect(mockSettings.syncMetadata['.claude/CLAUDE.md']).toMatchObject({ + lastSyncedSha: 'sha-hidden', + }); + }); + }); + + describe('pushFile with hidden paths', () => { + it('pushes hidden file content via string path', async () => { + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.read).mockResolvedValue('# Memory\n\nsome content'); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: '', content: '' }); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: '.claude/CLAUDE.md', sha: 'new-sha' }); + + await manager.pushFile('.claude/CLAUDE.md'); + + expect(mockGitService.pushFile).toHaveBeenCalledWith( + '.claude/CLAUDE.md', + '# Memory\n\nsome content', + 'main', + expect.any(String), + '' + ); + }); + + it('skips push when hidden file is already in sync', async () => { + const content = 'same content'; + vi.mocked(mockAdapter.exists).mockResolvedValue(true); + vi.mocked(mockAdapter.read).mockResolvedValue(content); + vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content }); + + await manager.pushFile('.claude/settings.json'); + + expect(mockGitService.pushFile).not.toHaveBeenCalled(); + }); + + it('does not exist notice when hidden file is missing', async () => { + vi.mocked(mockAdapter.exists).mockResolvedValue(false); + + await manager.pushFile('.claude/missing.json'); + + expect(mockGitService.pushFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 57cb7ca..e9dc277 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -173,6 +173,19 @@ describe('SyncManager', () => { expect(mockSettings.syncMetadata['test.md']?.lastSyncedSha).toBe('remote-sha'); }); + it('should update metadata even when file is already in sync (contentsEqual)', async () => { + const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); + mockSettings.syncMetadata = {}; + + vi.spyOn(mockApp.vault, 'read').mockResolvedValue('same content'); + vi.spyOn(mockGitLab, 'getFile').mockResolvedValueOnce({ content: 'same content', sha: 'remote-sha' }); + + await manager.pushFile(mockFile); + + expect(mockGitLab.pushFile).not.toHaveBeenCalled(); + expect(mockSettings.syncMetadata['test.md']?.lastSyncedSha).toBe('remote-sha'); + }); + it('should update metadata after successful push', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); mockSettings.syncMetadata = {}; diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 1bad1c1..5854ea4 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -110,6 +110,17 @@ describe('GitHubService', () => { ] } }); expect(await service.listFiles('main')).toEqual(['src/content/index.md']); }); + + it('should return files and log warning when result is truncated', async () => { + mockRequest({ status: 200, json: { truncated: true, tree: [ + { path: 'file1.md', type: 'blob' }, + { path: 'file2.md', type: 'blob' }, + ] } }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await service.listFiles('main'); + expect(result).toEqual(['file1.md', 'file2.md']); + warnSpy.mockRestore(); + }); }); describe('deleteFile', () => { diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 8c6fa56..d468c13 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitLabService } from '../../src/services/gitlab-service'; +import { RequestUrlResponse, requestUrl } from 'obsidian'; import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers'; describe('GitLabService', () => { @@ -87,6 +88,42 @@ describe('GitLabService', () => { ] }); expect(await service.listFiles('main')).toEqual(['src/content/index.md']); }); + + it('should fetch all pages when first page is exactly full (100 items)', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `file${i}.md`, type: 'blob' })); + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: [] } as unknown as RequestUrlResponse); + + const result = await service.listFiles('main'); + expect(result).toHaveLength(100); + expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2); + }); + + it('should fetch all pages across multiple full pages (200 items)', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `a/file${i}.md`, type: 'blob' })); + const page2 = Array.from({ length: 100 }, (_, i) => ({ path: `b/file${i}.md`, type: 'blob' })); + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: page2 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: [] } as unknown as RequestUrlResponse); + + const result = await service.listFiles('main'); + expect(result).toHaveLength(200); + expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(3); + }); + + it('should stop pagination when page has fewer than 100 items', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `file${i}.md`, type: 'blob' })); + const page2 = Array.from({ length: 42 }, (_, i) => ({ path: `extra/file${i}.md`, type: 'blob' })); + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: page2 } as unknown as RequestUrlResponse); + + const result = await service.listFiles('main'); + expect(result).toHaveLength(142); + expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2); + }); }); describe('deleteFile', () => { diff --git a/tests/utils/path.test.ts b/tests/utils/path.test.ts new file mode 100644 index 0000000..c1cbd35 --- /dev/null +++ b/tests/utils/path.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { isBinaryPath, contentsEqual, BINARY_EXTENSIONS } from '../../src/utils/path'; + +describe('isBinaryPath', () => { + describe('image formats', () => { + it('detects legacy formats', () => { + expect(isBinaryPath('photo.png')).toBe(true); + expect(isBinaryPath('photo.jpg')).toBe(true); + expect(isBinaryPath('photo.gif')).toBe(true); + expect(isBinaryPath('photo.bmp')).toBe(true); + expect(isBinaryPath('photo.webp')).toBe(true); + }); + + it('detects modern formats', () => { + expect(isBinaryPath('photo.heic')).toBe(true); + expect(isBinaryPath('photo.heif')).toBe(true); + expect(isBinaryPath('photo.avif')).toBe(true); + expect(isBinaryPath('photo.tiff')).toBe(true); + expect(isBinaryPath('photo.tif')).toBe(true); + expect(isBinaryPath('photo.jxl')).toBe(true); + }); + }); + + describe('audio formats', () => { + it('detects common audio formats', () => { + expect(isBinaryPath('song.mp3')).toBe(true); + expect(isBinaryPath('song.wav')).toBe(true); + expect(isBinaryPath('song.ogg')).toBe(true); + expect(isBinaryPath('song.flac')).toBe(true); + expect(isBinaryPath('song.aac')).toBe(true); + expect(isBinaryPath('song.m4a')).toBe(true); + expect(isBinaryPath('song.opus')).toBe(true); + expect(isBinaryPath('song.aiff')).toBe(true); + }); + }); + + describe('video formats', () => { + it('detects common video formats', () => { + expect(isBinaryPath('video.mp4')).toBe(true); + expect(isBinaryPath('video.webm')).toBe(true); + expect(isBinaryPath('video.mov')).toBe(true); + expect(isBinaryPath('video.mkv')).toBe(true); + expect(isBinaryPath('video.avi')).toBe(true); + expect(isBinaryPath('video.wmv')).toBe(true); + expect(isBinaryPath('video.flv')).toBe(true); + expect(isBinaryPath('video.m4v')).toBe(true); + }); + }); + + describe('archive formats', () => { + it('detects archive formats', () => { + expect(isBinaryPath('file.zip')).toBe(true); + expect(isBinaryPath('file.tar')).toBe(true); + expect(isBinaryPath('file.gz')).toBe(true); + expect(isBinaryPath('file.bz2')).toBe(true); + expect(isBinaryPath('file.xz')).toBe(true); + expect(isBinaryPath('file.zst')).toBe(true); + expect(isBinaryPath('file.7z')).toBe(true); + expect(isBinaryPath('file.rar')).toBe(true); + }); + }); + + describe('font formats', () => { + it('detects font formats including otf', () => { + expect(isBinaryPath('font.ttf')).toBe(true); + expect(isBinaryPath('font.otf')).toBe(true); + expect(isBinaryPath('font.woff')).toBe(true); + expect(isBinaryPath('font.woff2')).toBe(true); + }); + }); + + describe('database formats', () => { + it('detects database files', () => { + expect(isBinaryPath('data.sqlite')).toBe(true); + expect(isBinaryPath('data.db')).toBe(true); + }); + }); + + describe('text files', () => { + it('returns false for markdown', () => expect(isBinaryPath('note.md')).toBe(false)); + it('returns false for JSON', () => expect(isBinaryPath('settings.json')).toBe(false)); + it('returns false for TypeScript', () => expect(isBinaryPath('main.ts')).toBe(false)); + it('returns false for YAML', () => expect(isBinaryPath('config.yml')).toBe(false)); + it('returns false for plain text', () => expect(isBinaryPath('readme.txt')).toBe(false)); + }); + + describe('hidden file paths', () => { + it('returns false for dotfiles with no extension', () => { + expect(isBinaryPath('.gitignore')).toBe(false); + expect(isBinaryPath('.env')).toBe(false); + }); + + it('returns false for hidden directory text files', () => { + expect(isBinaryPath('.claude/settings.json')).toBe(false); + expect(isBinaryPath('.claude/CLAUDE.md')).toBe(false); + }); + + it('returns true for hidden directory binary files', () => { + expect(isBinaryPath('.claude/photo.png')).toBe(true); + }); + + it('returns false for hidden file with no extension', () => { + expect(isBinaryPath('.claude')).toBe(false); + }); + }); + + describe('case insensitivity', () => { + it('handles uppercase extensions', () => { + expect(isBinaryPath('photo.PNG')).toBe(true); + expect(isBinaryPath('photo.JPG')).toBe(true); + expect(isBinaryPath('font.TTF')).toBe(true); + }); + + it('handles mixed case extensions', () => { + expect(isBinaryPath('photo.Heic')).toBe(true); + expect(isBinaryPath('song.FLAC')).toBe(true); + }); + }); + + describe('BINARY_EXTENSIONS set completeness', () => { + it('contains all major image formats', () => { + ['png', 'jpg', 'jpeg', 'gif', 'webp', 'heic', 'heif', 'avif', 'tif', 'tiff'].forEach(ext => { + expect(BINARY_EXTENSIONS.has(ext), `missing: ${ext}`).toBe(true); + }); + }); + + it('contains all major audio formats', () => { + ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'opus'].forEach(ext => { + expect(BINARY_EXTENSIONS.has(ext), `missing: ${ext}`).toBe(true); + }); + }); + + it('contains both ttf and otf fonts', () => { + expect(BINARY_EXTENSIONS.has('ttf')).toBe(true); + expect(BINARY_EXTENSIONS.has('otf')).toBe(true); + }); + }); +}); + +describe('contentsEqual', () => { + describe('string comparison', () => { + it('returns true for identical strings', () => { + expect(contentsEqual('hello', 'hello')).toBe(true); + }); + + it('returns false for different strings', () => { + expect(contentsEqual('hello', 'world')).toBe(false); + }); + + it('returns true for empty strings', () => { + expect(contentsEqual('', '')).toBe(true); + }); + + it('is case-sensitive', () => { + expect(contentsEqual('Hello', 'hello')).toBe(false); + }); + }); + + describe('ArrayBuffer comparison', () => { + it('returns true for identical buffers', () => { + const bufA = new Uint8Array([1, 2, 3]).buffer; + const bufB = new Uint8Array([1, 2, 3]).buffer; + expect(contentsEqual(bufA, bufB)).toBe(true); + }); + + it('returns false for different buffers', () => { + const bufA = new Uint8Array([1, 2, 3]).buffer; + const bufB = new Uint8Array([1, 2, 4]).buffer; + expect(contentsEqual(bufA, bufB)).toBe(false); + }); + + it('returns false for buffers of different length', () => { + const bufA = new Uint8Array([1, 2, 3]).buffer; + const bufB = new Uint8Array([1, 2]).buffer; + expect(contentsEqual(bufA, bufB)).toBe(false); + }); + + it('returns true for two empty buffers', () => { + expect(contentsEqual(new ArrayBuffer(0), new ArrayBuffer(0))).toBe(true); + }); + }); + + describe('mixed type comparison', () => { + it('returns false when comparing string with ArrayBuffer', () => { + const buf = new Uint8Array([1, 2, 3]).buffer; + expect(contentsEqual('hello', buf)).toBe(false); + }); + + it('returns false when comparing ArrayBuffer with string', () => { + const buf = new Uint8Array([1, 2, 3]).buffer; + expect(contentsEqual(buf, 'hello')).toBe(false); + }); + }); +});