firstsun-dev_git-files-sync/tests/services/github-service.test.ts
Claude 62b475d632
feat(sync): detect symbolic links and add a configurable handling setting
Symlinks are Git blobs with mode 120000 whose content is the target path.
They were treated as ordinary files, which caused 404s on fetch and could
corrupt the file on pull. Add detection and let the user choose behavior.

- Settings: new "Symbolic links" option (symlinkHandling) with real
  (default) / follow / skip.
- Services: listFilesDetailed() reports each entry's symlink flag from the
  tree mode (120000) for GitHub and GitLab; listFiles() now delegates to it.
- Refresh/discovery: with "skip", remote symlinks are excluded from sync;
  with "follow"/"real" they are included and synced as the target content.

Note: true OS-level symlink recreation on desktop and pushing files as
symlinks (Git Data API) are not yet implemented — on all platforms "real"
currently syncs the target content like "follow"; mobile has no symlink
API regardless. Tracked as a follow-up.

Add tests for symlink detection on both services and update settings fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe
2026-06-28 04:45:41 +00:00

180 lines
8 KiB
TypeScript

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;
const token = 'test-token';
const owner = 'test-owner';
const repo = 'test-repo';
beforeEach(() => {
vi.clearAllMocks();
service = new GitHubService();
service.updateConfig(token, owner, repo);
});
describe('getFile', () => {
it('should fetch and decode file content correctly', async () => {
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 () => {
mockRequest({ status: 404 });
const result = await service.getFile('missing.md', 'main');
expect(result.content).toBe('');
expect(result.sha).toBe('');
});
it('should bypass rootPath when path starts with / (absolute repo path)', async () => {
service.updateConfig(token, owner, repo, 'vault');
mockRequest({ status: 200, json: { content: btoa('root content'), sha: 'root-sha' } });
await service.getFile('/.gitignore', 'main');
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');
mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } });
await service.getFile('src/content/index.md', 'main');
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 () => {
mockRequest({ status: 200, json: { content: btoa('test'), sha: 'explicit-sha' } });
const result = await service.getFile('test.md', 'main');
expect(result.sha).toBe('explicit-sha');
});
});
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);
const result = await service.pushFile('new.md', 'new content', 'main', 'create');
expect(result).toEqual({ path: 'new.md', sha: 'new-sha' });
const call = getLastRequestCall();
expect(call.method).toBe('PUT');
expect(call.body).not.toContain('"sha":');
});
it('should omit blank sha so creating a new file does not 422', async () => {
// A 404 lookup yields sha === '' for new files; an empty sha sent to
// GitHub causes HTTP 422, so it must be dropped from the request body.
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', 'content', 'main', 'create', '');
expect(result).toEqual({ path: 'new.md', sha: 'new-sha' });
const call = getLastRequestCall();
expect(call.body).not.toContain('"sha":');
});
it('should update existing file correctly (sha provided)', async () => {
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 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 () => {
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');
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('listFilesDetailed flags symlinks (mode 120000)', async () => {
mockRequest({ status: 200, json: { tree: [
{ path: 'real.md', type: 'blob', mode: '100644' },
{ path: 'link.md', type: 'blob', mode: '120000' },
{ path: 'dir', type: 'tree', mode: '040000' },
] } });
expect(await service.listFilesDetailed('main')).toEqual([
{ path: 'real.md', symlink: false },
{ path: 'link.md', symlink: true },
]);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(token, owner, repo, 'src/content');
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']);
});
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', () => {
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);
await service.deleteFile('test.md', 'main', 'delete test.md');
const calls = vi.mocked(requestUrl).mock.calls;
expect(calls).toHaveLength(2);
const deleteCall = calls[1]?.[0] as RequestUrlParam;
expect(deleteCall.method).toBe('DELETE');
expect(deleteCall.body).toContain('"sha":"file-sha"');
});
});
describe('testConnection', () => {
sharedTestConnection(() => service);
});
describe('getRepoGitignores', () => {
sharedGetRepoGitignores(() => service, 'tree');
});
describe('getFile error handling', () => {
sharedGetFileErrorHandling(() => service);
});
});