martinlegend_neogdsync/tests/pathIndex.test.ts
ml2310 b0de078e15
v1.0.9: fix data-loss and correctness bugs across the sync engine (rebased onto v1.0.8) (#5)
Correctness:
- Edits made while a sync runs are no longer dropped. Vault event handlers
  now ignore only paths the sync itself wrote (Syncer.syncWrites) instead of
  suppressing all events behind a global flag, and pending ops are cleared
  per-op right after success instead of in a bulk sweep that also wiped
  ops re-queued mid-sync.
- Renames now propagate to Drive: handlePush compares the Drive-side name
  and calls files.rename, so a local a.md -> b.md no longer leaves the old
  name on Drive (which later caused duplicate uploads after a rebuild).
- A 'modify' op with no index entry (index lost/reset) looks the file up on
  Drive by path before uploading, preventing duplicate files on Drive.
- Excluded paths queued in pendingOps are cleared instead of sticking in
  the "N pending" counter forever; exclusion logic is now shared between
  event handlers and the sync engine (src/exclude.ts), so user glob
  patterns apply consistently. computeDiff no longer reports
  newly-excluded snapshot entries as deletions.
- Files first seen via unknown Drive changes (syncedAt=0) now go through
  conflict handling instead of silently overwriting the remote copy.
- index.db is written via tmp-file + rename with recovery on load, so a
  crash mid-save can no longer silently reset the index.
- Multipart upload boundary is randomized; a file containing the fixed
  boundary string no longer corrupts the upload.

Auth / settings:
- authProxyUrl is actually used now (it was silently ignored) and is
  editable in settings; DriveApi credentials update in place so PathIndex
  never holds a stale instance; token cache is keyed by token+proxy.
- Exclude patterns are editable in settings (README advertised this but
  there was no UI).

Performance / UX:
- Force Pull and pull-new-from-Drive download with a small concurrency
  pool (settings.concurrency, previously dead config).
- Unknown-change resolution fetches each file's metadata once instead of
  twice.
- First run with a non-empty vault shows a notice explaining that an
  initial Force Push/Pull is required.

Security / hygiene:
- OAuth proxy escapes HTML in the callback pages (reflected XSS via
  ?error=...), and README now accurately describes that token refresh goes
  through the proxy and how to self-host it.
- Removed stale compiled artifacts (src/*.js) and dead code
  (listRevisions); fixed package.json metadata (MIT, author, scripts).
- Added vitest with unit tests for exclusion, snapshot diffing, and index
  persistence/recovery (17 tests).


Claude-Session: https://claude.ai/code/session_011DimwV9zDr1ViCXRCZPxC9

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 11:56:55 +08:00

86 lines
3.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { App } from 'obsidian';
import { PathIndex } from '../src/pathIndex';
import { DriveApi } from '../src/driveApi';
/** In-memory DataAdapter covering what PathIndex uses. */
function makeApp(files: Map<string, string> = new Map()): { app: App; files: Map<string, string> } {
const adapter = {
exists: async (p: string) => files.has(p) || [...files.keys()].some(k => k.startsWith(p + '/')),
read: async (p: string) => {
if (!files.has(p)) throw new Error(`ENOENT: ${p}`);
return files.get(p) as string;
},
write: async (p: string, data: string) => { files.set(p, data); },
remove: async (p: string) => { files.delete(p); },
rename: async (from: string, to: string) => {
if (!files.has(from)) throw new Error(`ENOENT: ${from}`);
files.set(to, files.get(from) as string);
files.delete(from);
},
mkdir: async (p: string) => { files.set(p + '/.dir', ''); },
};
const app = { vault: { adapter, configDir: '.obsidian' } } as unknown as App;
return { app, files };
}
const fakeDrive = {} as DriveApi;
const entry = (driveId: string, isFolder = false) =>
({ driveId, driveMtime: '2026-01-01T00:00:00Z', syncedAt: 1, isFolder });
describe('PathIndex core ops', () => {
it('set/get/delete/rename/allPaths', () => {
const idx = new PathIndex(makeApp().app, fakeDrive, 'root');
idx.set('a/b.md', entry('id1'));
expect(idx.get('a/b.md')?.driveId).toBe('id1');
idx.rename('a/b.md', 'a/c.md');
expect(idx.get('a/b.md')).toBeUndefined();
expect(idx.get('a/c.md')?.driveId).toBe('id1');
expect(idx.allPaths()).toEqual(['a/c.md']);
idx.delete('a/c.md');
expect(idx.allPaths()).toEqual([]);
});
});
describe('PathIndex persistence', () => {
it('round-trips through save/load', async () => {
const { app, files } = makeApp();
const idx = new PathIndex(app, fakeDrive, 'root');
idx.set('n.md', entry('id9'));
await idx.save();
expect(files.has('.neogdsync/index.db')).toBe(true);
expect(files.has('.neogdsync/index.db.tmp')).toBe(false);
const idx2 = new PathIndex(app, fakeDrive, 'root');
await idx2.load();
expect(idx2.get('n.md')?.driveId).toBe('id9');
});
it('recovers from a crash that left only the .tmp file', async () => {
const { app, files } = makeApp();
files.set('.neogdsync/index.db.tmp', JSON.stringify({ 'x.md': entry('idX') }));
const idx = new PathIndex(app, fakeDrive, 'root');
await idx.load();
expect(idx.get('x.md')?.driveId).toBe('idX');
});
it('falls back to .tmp when the main index is corrupt', async () => {
const { app, files } = makeApp();
files.set('.neogdsync/index.db', '{not json');
files.set('.neogdsync/index.db.tmp', JSON.stringify({ 'y.md': entry('idY') }));
const idx = new PathIndex(app, fakeDrive, 'root');
await idx.load();
expect(idx.get('y.md')?.driveId).toBe('idY');
});
it('starts empty when nothing is readable', async () => {
const idx = new PathIndex(makeApp().app, fakeDrive, 'root');
await idx.load();
expect(idx.allPaths()).toEqual([]);
});
});