mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
test: expand coverage for binary files, hidden paths, and edge cases
- Add sync-manager-binary.test.ts: push/pull ArrayBuffer via adapter.readBinary/writeBinary - Add sync-manager-hidden.test.ts: hidden path mkdir, push, pull via string paths - Add utils/path.test.ts: isBinaryPath and contentsEqual full coverage - Add metadata-on-equal assertion to sync-manager tests - Add GitHub truncated result and GitLab pagination boundary tests - Add hidden file gitignore filter tests - Add docs/test-coverage.md with all 189 test cases documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
49acf05241
commit
fcb9447733
8 changed files with 853 additions and 0 deletions
227
docs/test-coverage.md
Normal file
227
docs/test-coverage.md
Normal file
|
|
@ -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 |
|
||||
|
|
@ -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<DataAdapter>;
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('should ignore hidden directory when listed in .gitignore', async () => {
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
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<DataAdapter>;
|
||||
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<DataAdapter>;
|
||||
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<DataAdapter>;
|
||||
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<DataAdapter>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
157
tests/logic/sync-manager-binary.test.ts
Normal file
157
tests/logic/sync-manager-binary.test.ts
Normal file
|
|
@ -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<App>;
|
||||
let mockGitService: Mocked<GitServiceInterface>;
|
||||
let mockAdapter: Mocked<DataAdapter>;
|
||||
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<DataAdapter>;
|
||||
|
||||
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<App>;
|
||||
|
||||
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<GitServiceInterface>;
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
155
tests/logic/sync-manager-hidden.test.ts
Normal file
155
tests/logic/sync-manager-hidden.test.ts
Normal file
|
|
@ -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<App>;
|
||||
let mockGitService: Mocked<GitServiceInterface>;
|
||||
let mockAdapter: Mocked<DataAdapter>;
|
||||
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<DataAdapter>;
|
||||
|
||||
mockApp = {
|
||||
vault: {
|
||||
read: vi.fn(),
|
||||
modify: vi.fn(),
|
||||
getFileByPath: vi.fn().mockReturnValue(null),
|
||||
adapter: mockAdapter,
|
||||
},
|
||||
} as unknown as Mocked<App>;
|
||||
|
||||
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<GitServiceInterface>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
194
tests/utils/path.test.ts
Normal file
194
tests/utils/path.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue