mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
1421 lines
57 KiB
TypeScript
1421 lines
57 KiB
TypeScript
import {
|
|
backoffSeconds,
|
|
isAllowedByOperator,
|
|
isBlockedName,
|
|
needsFullListing,
|
|
planReconcile,
|
|
sanitizeIncomingName,
|
|
type LocalDocument,
|
|
type SyncItemState,
|
|
} from '../../../../src/nest/doc-sync/doc-sync.helpers';
|
|
import type { RemoteDocument } from '../../../../src/nest/doc-sync/document-provider';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
/**
|
|
* The planner decides everything that matters about two-way sync, and it does
|
|
* it without a database, a container or a network. That is the point of having
|
|
* it: the cases that are painful to reproduce against a live provider (an
|
|
* unmounted share, a rename with no stable id, TREK's own write echoing back)
|
|
* are cheap to state here as data.
|
|
*/
|
|
|
|
const remote = (over: Partial<RemoteDocument> = {}): RemoteDocument => ({
|
|
remoteId: 'r1',
|
|
name: 'boarding.pdf',
|
|
size: 1024,
|
|
mimeType: 'application/pdf',
|
|
remoteVersion: 'v1',
|
|
contentHash: null,
|
|
remoteModifiedAt: '2026-09-18T10:00:00Z',
|
|
isDeleted: false,
|
|
...over,
|
|
});
|
|
|
|
const local = (over: Partial<LocalDocument> = {}): LocalDocument => ({
|
|
fileId: 1,
|
|
name: 'boarding.pdf',
|
|
size: 1024,
|
|
mimeType: 'application/pdf',
|
|
sha256: 'aaa',
|
|
deletedAt: null,
|
|
...over,
|
|
});
|
|
|
|
const item = (over: Partial<SyncItemState> = {}): SyncItemState => ({
|
|
id: 10,
|
|
fileId: 1,
|
|
trekDocUid: 'uid-1',
|
|
remoteId: 'r1',
|
|
remoteVersion: 'v1',
|
|
remoteName: 'boarding.pdf',
|
|
remoteSize: null,
|
|
remoteModifiedAt: null,
|
|
contentSha256: 'aaa',
|
|
pushedSha256: null,
|
|
state: 'synced',
|
|
attempts: 0,
|
|
nextAttemptAt: null,
|
|
remoteMissingAt: null,
|
|
remoteTrashedAt: null,
|
|
...over,
|
|
});
|
|
|
|
const plan = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) =>
|
|
planReconcile({
|
|
items: [],
|
|
remote: [],
|
|
local: [],
|
|
direction: 'both',
|
|
remoteTruncated: false,
|
|
remoteUnchanged: false,
|
|
conflictPolicy: 'manual',
|
|
stableRemoteIds: true,
|
|
maxAttempts: 6,
|
|
...over,
|
|
});
|
|
|
|
describe('planReconcile', () => {
|
|
it('pulls a document that only exists at the provider', () => {
|
|
const p = plan({ remote: [remote()] });
|
|
expect(p.actions).toEqual([{ kind: 'pull', remote: remote(), itemId: null }]);
|
|
});
|
|
|
|
it('pushes a document that only exists in TREK', () => {
|
|
const p = plan({ local: [local()] });
|
|
expect(p.actions).toEqual([{ kind: 'push', local: local(), itemId: null, remoteId: null }]);
|
|
});
|
|
|
|
it('does nothing when both sides match', () => {
|
|
const p = plan({ items: [item()], remote: [remote()], local: [local()] });
|
|
expect(p.actions).toEqual([{ kind: 'touch', itemId: 10, remote: remote() }]);
|
|
});
|
|
|
|
it('pulls an update when only the provider moved on', () => {
|
|
const p = plan({ items: [item()], remote: [remote({ remoteVersion: 'v2' })], local: [local()] });
|
|
expect(p.actions[0].kind).toBe('pull_update');
|
|
});
|
|
|
|
it('pushes an update when only TREK moved on', () => {
|
|
const p = plan({ items: [item()], remote: [remote()], local: [local({ sha256: 'bbb' })] });
|
|
expect(p.actions[0]).toMatchObject({ kind: 'push_update', remoteId: 'r1' });
|
|
});
|
|
|
|
it('reports a conflict when both sides moved on', () => {
|
|
const p = plan({
|
|
items: [item()],
|
|
remote: [remote({ remoteVersion: 'v2' })],
|
|
local: [local({ sha256: 'bbb' })],
|
|
});
|
|
expect(p.actions[0].kind).toBe('conflict');
|
|
});
|
|
|
|
/**
|
|
* The echo guard. A provider reports TREK's own upload as a change, and
|
|
* without this the file would be pulled back down, re-uploaded, and bounce
|
|
* forever. It compares content rather than timestamps on purpose: a time
|
|
* window misfires on any clock skew between TREK and a NAS.
|
|
*/
|
|
it("treats a change whose content matches TREK's own push as no change", () => {
|
|
const p = plan({
|
|
items: [item({ pushedSha256: 'aaa' })],
|
|
remote: [remote({ remoteVersion: 'v2', contentHash: 'aaa' })],
|
|
local: [local()],
|
|
});
|
|
expect(p.actions[0].kind).toBe('touch');
|
|
});
|
|
|
|
it('still sees a real foreign change when the content differs from what was pushed', () => {
|
|
const p = plan({
|
|
items: [item({ pushedSha256: 'aaa' })],
|
|
remote: [remote({ remoteVersion: 'v2', contentHash: 'ccc' })],
|
|
local: [local()],
|
|
});
|
|
expect(p.actions[0].kind).toBe('pull_update');
|
|
});
|
|
|
|
/**
|
|
* A version marker that moved over the same bytes. Paperless bumps
|
|
* `modified` for a tag, a correspondent or a title, and a pulled document
|
|
* has no `pushedSha256` for the echo guard to read: every such edit fetched
|
|
* the same bytes again, put them in a fresh row without the booking link the
|
|
* old one carried, and sent the old row to the trash.
|
|
*/
|
|
it('treats a marker that moved over the agreed bytes as no change either', () => {
|
|
const p = plan({
|
|
items: [item({ contentSha256: 'aaa', pushedSha256: null })],
|
|
remote: [remote({ remoteVersion: 'v2', contentHash: 'aaa' })],
|
|
local: [local()],
|
|
});
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'touch', itemId: 10, remote: remote({ remoteVersion: 'v2', contentHash: 'aaa' }) },
|
|
]);
|
|
});
|
|
|
|
it('still follows a rename that came with such an edit', () => {
|
|
const renamed = remote({ remoteVersion: 'v2', contentHash: 'aaa', name: 'hotel-booking.pdf' });
|
|
const p = plan({
|
|
items: [item({ contentSha256: 'aaa', pushedSha256: null })],
|
|
remote: [renamed],
|
|
local: [local()],
|
|
});
|
|
expect(p.actions).toEqual([{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'hotel-booking.pdf' }]);
|
|
});
|
|
|
|
it('still pulls when the listing carries no hash to compare', () => {
|
|
const p = plan({
|
|
items: [item({ contentSha256: 'aaa', pushedSha256: null })],
|
|
remote: [remote({ remoteVersion: 'v2', contentHash: null })],
|
|
local: [local()],
|
|
});
|
|
expect(p.actions[0].kind).toBe('pull_update');
|
|
});
|
|
|
|
it('still retries a row in error over the same bytes, since touch would leave it there for good', () => {
|
|
const p = plan({
|
|
items: [item({ state: 'error', contentSha256: 'aaa', pushedSha256: null })],
|
|
remote: [remote({ remoteVersion: 'v2', contentHash: 'aaa' })],
|
|
local: [local()],
|
|
});
|
|
expect(p.actions[0].kind).toBe('pull_update');
|
|
});
|
|
|
|
/**
|
|
* An unmounted share and a revoked token both answer with a short listing.
|
|
* Reading that as a mass deletion would empty a trip, so the run refuses
|
|
* everything rather than acting on a listing it cannot trust.
|
|
*/
|
|
it('refuses the run when most known documents vanish at once', () => {
|
|
const items = Array.from({ length: 10 }, (_, i) => item({ id: i, remoteId: `r${i}`, fileId: i }));
|
|
const p = plan({ items, remote: [remote({ remoteId: 'r0' })], local: [] });
|
|
expect(p.massDeleteGuardTripped).toBe(true);
|
|
expect(p.actions.filter((a) => a.kind === 'mark_remote_missing')).toHaveLength(0);
|
|
});
|
|
|
|
it('records a single disappearance without acting on it', () => {
|
|
const items = Array.from({ length: 10 }, (_, i) => item({ id: i, remoteId: `r${i}`, fileId: i }));
|
|
const stillThere = items.slice(1).map((i) => remote({ remoteId: i.remoteId as string }));
|
|
const p = plan({ items, remote: stillThere, local: [] });
|
|
expect(p.massDeleteGuardTripped).toBe(false);
|
|
expect(p.actions.filter((a) => a.kind === 'mark_remote_missing')).toHaveLength(1);
|
|
});
|
|
|
|
it('never marks anything missing from a truncated listing', () => {
|
|
const items = Array.from({ length: 10 }, (_, i) => item({ id: i, remoteId: `r${i}`, fileId: i }));
|
|
const p = plan({ items, remote: [], local: [], remoteTruncated: true });
|
|
expect(p.actions.filter((a) => a.kind === 'mark_remote_missing')).toHaveLength(0);
|
|
});
|
|
|
|
/**
|
|
* Which side renamed is decided against the name both sides last agreed on.
|
|
* Reading a `both` binding as "TREK always wins" renamed the provider's copy
|
|
* back every time somebody tidied a folder, which is the opposite of what a
|
|
* two-way sync is for.
|
|
*/
|
|
it('renames at the provider when TREK is the side that renamed', () => {
|
|
const p = plan({
|
|
items: [item({ remoteName: 'old.pdf' })],
|
|
remote: [remote({ name: 'old.pdf' })],
|
|
local: [local({ name: 'new.pdf' })],
|
|
});
|
|
expect(p.actions[0]).toMatchObject({ kind: 'rename_remote', name: 'new.pdf' });
|
|
});
|
|
|
|
it('renames in TREK when the provider is the side that renamed', () => {
|
|
const p = plan({
|
|
items: [item({ remoteName: 'old.pdf' })],
|
|
remote: [remote({ name: 'new-upstream.pdf' })],
|
|
local: [local({ name: 'old.pdf' })],
|
|
});
|
|
expect(p.actions[0]).toMatchObject({ kind: 'rename_local', name: 'new-upstream.pdf' });
|
|
});
|
|
|
|
it('lets the provider win when the binding says so', () => {
|
|
// The answer was stored at setup and never read: every conflict was manual
|
|
// whatever the binding said, so a person who had already picked a side was
|
|
// asked to pick it again, for good.
|
|
const p = plan({
|
|
conflictPolicy: 'provider_wins',
|
|
items: [item({ contentSha256: 'old', remoteVersion: 'v1' })],
|
|
remote: [remote({ remoteVersion: 'v2' })],
|
|
local: [local({ sha256: 'newer' })],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['pull_update']);
|
|
});
|
|
|
|
it('lets TREK win when the binding says so', () => {
|
|
const p = plan({
|
|
conflictPolicy: 'trek_wins',
|
|
items: [item({ contentSha256: 'old', remoteVersion: 'v1' })],
|
|
remote: [remote({ remoteVersion: 'v2' })],
|
|
local: [local({ sha256: 'newer' })],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['push_update']);
|
|
});
|
|
|
|
it('will not resolve a conflict by doing what the direction forbids', () => {
|
|
// A pull-only binding cannot push, so "TREK wins" cannot mean "upload it".
|
|
// Parking is the honest answer; the alternative is silently doing the
|
|
// opposite of one of the two settings.
|
|
const p = plan({
|
|
conflictPolicy: 'trek_wins',
|
|
direction: 'pull',
|
|
items: [item({ contentSha256: 'old', remoteVersion: 'v1' })],
|
|
remote: [remote({ remoteVersion: 'v2' })],
|
|
local: [local({ sha256: 'newer' })],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['conflict']);
|
|
});
|
|
|
|
it('asks a person when both sides renamed', () => {
|
|
const p = plan({
|
|
items: [item({ remoteName: 'old.pdf' })],
|
|
remote: [remote({ name: 'theirs.pdf' })],
|
|
local: [local({ name: 'mine.pdf' })],
|
|
});
|
|
expect(p.actions[0].kind).toBe('conflict');
|
|
});
|
|
|
|
it('does nothing about a name difference it cannot arbitrate', () => {
|
|
// No stored name: a row from before the column existed, or a pairing made
|
|
// in the same run. Guessing here would rename somebody's file.
|
|
const p = plan({
|
|
items: [item({ remoteName: null })],
|
|
remote: [remote({ name: 'a.pdf' })],
|
|
local: [local({ name: 'b.pdf' })],
|
|
});
|
|
expect(p.actions[0].kind).toBe('touch');
|
|
});
|
|
|
|
it('honours a pull-only binding by never pushing', () => {
|
|
const p = plan({ local: [local()], remote: [], direction: 'pull' });
|
|
expect(p.actions).toHaveLength(0);
|
|
});
|
|
|
|
it('honours a push-only binding by never pulling', () => {
|
|
const p = plan({ remote: [remote()], direction: 'push' });
|
|
expect(p.actions).toHaveLength(0);
|
|
});
|
|
|
|
it('does not re-upload a document whose remote copy is merely unlisted', () => {
|
|
// The row has a remoteId but the listing does not contain it. Re-pushing
|
|
// here would duplicate the whole trip upstream every time a listing fails.
|
|
const p = plan({ items: [item()], remote: [], local: [local()] });
|
|
expect(p.actions.some((a) => a.kind === 'push')).toBe(false);
|
|
});
|
|
|
|
it('reports a local deletion instead of acting on it', () => {
|
|
const p = plan({ items: [item()], remote: [remote()], local: [local({ deletedAt: '2026-09-18' })] });
|
|
expect(p.actions[0]).toMatchObject({ kind: 'local_deleted', remoteId: 'r1' });
|
|
});
|
|
|
|
it('ignores a provider-side tombstone rather than pulling it', () => {
|
|
const p = plan({ remote: [remote({ isDeleted: true })] });
|
|
expect(p.actions).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeIncomingName', () => {
|
|
it('strips directory traversal from both path flavours', () => {
|
|
expect(sanitizeIncomingName('../../etc/passwd')).toBe('passwd');
|
|
expect(sanitizeIncomingName('C:\\Windows\\system32\\evil.pdf')).toBe('evil.pdf');
|
|
});
|
|
|
|
it('removes control characters and leading dots', () => {
|
|
expect(sanitizeIncomingName('.hidden\u0000.pdf')).toBe('hidden.pdf');
|
|
});
|
|
|
|
it('replaces characters that are illegal in a filename', () => {
|
|
expect(sanitizeIncomingName('a:b*c?.pdf')).toBe('a_b_c_.pdf');
|
|
});
|
|
|
|
it('never returns an empty name', () => {
|
|
expect(sanitizeIncomingName('...')).toBe('document');
|
|
expect(sanitizeIncomingName('')).toBe('document');
|
|
});
|
|
});
|
|
|
|
describe('isBlockedName', () => {
|
|
it('blocks the extensions TREK serves inline', () => {
|
|
// A download is served with a Content-Type derived from the extension, so
|
|
// these would be stored XSS if a provider folder could introduce them.
|
|
expect(isBlockedName('map.svg')).toBe(true);
|
|
expect(isBlockedName('page.html')).toBe(true);
|
|
expect(isBlockedName('run.exe')).toBe(true);
|
|
});
|
|
|
|
it('allows ordinary documents', () => {
|
|
expect(isBlockedName('boarding.pdf')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isAllowedByOperator', () => {
|
|
it('honours the wildcard', () => {
|
|
expect(isAllowedByOperator('x.pdf', '*')).toBe(true);
|
|
});
|
|
|
|
it('matches case-insensitively and tolerates dots and spaces in the list', () => {
|
|
expect(isAllowedByOperator('x.PDF', ' .pdf , jpg ')).toBe(true);
|
|
});
|
|
|
|
it('refuses a file with no extension even under the wildcard', () => {
|
|
expect(isAllowedByOperator('README', '*')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('backoffSeconds', () => {
|
|
it('saturates at the end of the curve instead of running off it', () => {
|
|
expect(backoffSeconds([60, 300], 1)).toBe(60);
|
|
expect(backoffSeconds([60, 300], 2)).toBe(300);
|
|
expect(backoffSeconds([60, 300], 99)).toBe(300);
|
|
});
|
|
|
|
it('treats a zero failure count as the first step', () => {
|
|
expect(backoffSeconds([60, 300], 0)).toBe(60);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The retry limit, read back out.
|
|
*
|
|
* Found in a live database at 46 attempts against a limit of 6: the counter and
|
|
* the backoff were written on every failure and never consulted when planning
|
|
* the next run, so a document a provider refuses (a file it reads as corrupt,
|
|
* a type it will not take, a full quota) was re-uploaded on every cron tick
|
|
* for as long as it existed.
|
|
*/
|
|
describe('planReconcile > shelving a row that keeps failing', () => {
|
|
const failing = (over: Partial<SyncItemState> = {}) =>
|
|
item({
|
|
id: 20,
|
|
remoteId: null,
|
|
remoteVersion: null,
|
|
remoteName: null,
|
|
contentSha256: null,
|
|
state: 'error',
|
|
...over,
|
|
});
|
|
|
|
it('re-plans a failed push while attempts are left', () => {
|
|
const p = plan({
|
|
items: [failing({ attempts: 2 })],
|
|
local: [local({ fileId: 1 })],
|
|
maxAttempts: 6,
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toContain('push');
|
|
});
|
|
|
|
it('stops re-planning once the attempts are used up', () => {
|
|
const p = plan({
|
|
items: [failing({ attempts: 6 })],
|
|
local: [local({ fileId: 1 })],
|
|
maxAttempts: 6,
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).not.toContain('push');
|
|
});
|
|
|
|
it('stops re-planning past the limit as well, not only exactly at it', () => {
|
|
const p = plan({
|
|
items: [failing({ attempts: 46 })],
|
|
local: [local({ fileId: 1 })],
|
|
maxAttempts: 6,
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('holds off while the backoff window is still open', () => {
|
|
const p = plan({
|
|
items: [failing({ attempts: 1, nextAttemptAt: '2030-01-01 00:00:00' })],
|
|
local: [local({ fileId: 1 })],
|
|
maxAttempts: 6,
|
|
now: '2026-01-01 00:00:00',
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('tries again once the backoff window has passed', () => {
|
|
const p = plan({
|
|
items: [failing({ attempts: 1, nextAttemptAt: '2026-01-01 00:00:00' })],
|
|
local: [local({ fileId: 1 })],
|
|
maxAttempts: 6,
|
|
now: '2026-06-01 00:00:00',
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toContain('push');
|
|
});
|
|
|
|
it('leaves a healthy row alone however high its old attempt count is', () => {
|
|
// attempts is not reset on success, so a row that failed twice and then
|
|
// went through must not be mistaken for a shelved one.
|
|
const p = plan({
|
|
items: [item({ attempts: 9, state: 'synced' })],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v2' })],
|
|
local: [local({ fileId: 1 })],
|
|
maxAttempts: 6,
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toContain('pull_update');
|
|
});
|
|
|
|
it('leaves a shelved row alone even when its provider copy is gone too', () => {
|
|
// "Vanished" is only said about a row that was synced last run, and a row is
|
|
// only shelved while it is in error, so these two never describe the same
|
|
// row. Nothing is planned for it either way.
|
|
const p = plan({
|
|
items: [item({ id: 30, attempts: 99, state: 'error', remoteId: 'r-gone' })],
|
|
remote: [],
|
|
local: [],
|
|
maxAttempts: 6,
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* An unchanged upstream, which the WebDAV adapters report by NOT fetching.
|
|
*
|
|
* Their root-ETag probe answers "nothing has happened here" in one request, and
|
|
* they then return an empty document list. The core ignored the flag and read
|
|
* that emptiness as a folder somebody had emptied: from the third run of every
|
|
* idle Nextcloud or OpenCloud binding onward, every run ended in the mass-delete
|
|
* guard: `partial`, `missing: 6`, forever, until the failure counter opened the
|
|
* circuit and the scheduler dropped the binding entirely. Reproduced against a
|
|
* live Nextcloud before this was written.
|
|
*/
|
|
describe('planReconcile > upstream reported as unchanged', () => {
|
|
it('does not read an empty listing as a mass deletion', () => {
|
|
const p = plan({
|
|
items: [item({ id: 1, remoteId: 'r1' }), item({ id: 2, fileId: 2, remoteId: 'r2' })],
|
|
remote: [],
|
|
local: [local({ fileId: 1 }), local({ fileId: 2 })],
|
|
remoteUnchanged: true,
|
|
});
|
|
expect(p.massDeleteGuardTripped).toBe(false);
|
|
expect(p.missingCount).toBe(0);
|
|
});
|
|
|
|
it('marks nothing as gone from the provider', () => {
|
|
const p = plan({
|
|
items: [item({ id: 1, remoteId: 'r1' })],
|
|
remote: [],
|
|
local: [local({ fileId: 1 })],
|
|
remoteUnchanged: true,
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).not.toContain('mark_remote_missing');
|
|
});
|
|
|
|
it('still pushes a document TREK gained in the meantime', () => {
|
|
const p = plan({
|
|
items: [],
|
|
remote: [],
|
|
local: [local({ fileId: 7, name: 'new.pdf' })],
|
|
remoteUnchanged: true,
|
|
});
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'push', local: expect.objectContaining({ fileId: 7 }), itemId: null, remoteId: null },
|
|
]);
|
|
});
|
|
|
|
it('still reports a document TREK deleted in the meantime', () => {
|
|
const p = plan({
|
|
items: [item({ id: 5, fileId: 3, remoteId: 'r3' })],
|
|
remote: [],
|
|
local: [local({ fileId: 3, deletedAt: '2026-09-18 10:00:00' })],
|
|
remoteUnchanged: true,
|
|
});
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 5, remoteId: 'r3' }]);
|
|
});
|
|
|
|
it('does not push out of a pull-only binding', () => {
|
|
const p = plan({
|
|
items: [],
|
|
remote: [],
|
|
local: [local({ fileId: 7 })],
|
|
direction: 'pull',
|
|
remoteUnchanged: true,
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('plans nothing at all when neither side moved', () => {
|
|
const p = plan({
|
|
items: [item({ id: 1, remoteId: 'r1' })],
|
|
remote: [],
|
|
local: [local({ fileId: 1 })],
|
|
remoteUnchanged: true,
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('works the old way when the provider does report a change', () => {
|
|
// The same shape with the flag off is exactly the case that used to break:
|
|
// an empty listing then really does mean everything vanished.
|
|
const many = Array.from({ length: 6 }, (_, i) => item({ id: i + 1, fileId: i + 1, remoteId: `r${i + 1}` }));
|
|
const p = plan({
|
|
items: many,
|
|
remote: [],
|
|
local: many.map((_, i) => local({ fileId: i + 1 })),
|
|
remoteUnchanged: false,
|
|
});
|
|
expect(p.massDeleteGuardTripped).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A download that failed once must be tried again.
|
|
*
|
|
* `pull()` records the failure together with the remote's current version, so
|
|
* the next run computed `remoteChanged = false`; with no local file the other
|
|
* branches had nothing to compare and the row fell through to `touch`. Nothing
|
|
* in the planner ever planned a second attempt, so the document never arrived,
|
|
* the attempt counter never grew past one, and "Sync now" produced the same
|
|
* plan. The asymmetry was visible in these tests: a failed PUSH was covered,
|
|
* because the TREK-only branch re-plans it; a failed PULL was not.
|
|
*/
|
|
describe('planReconcile > a pairing whose download never landed', () => {
|
|
const failedPull = (over: Partial<SyncItemState> = {}) =>
|
|
item({ id: 40, fileId: null, remoteId: 'r1', remoteVersion: 'v1', contentSha256: null, state: 'error', ...over });
|
|
|
|
it('plans another download', () => {
|
|
const p = plan({
|
|
items: [failedPull()],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v1' })],
|
|
local: [],
|
|
});
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'pull_update', remote: expect.objectContaining({ remoteId: 'r1' }), itemId: 40 },
|
|
]);
|
|
});
|
|
|
|
it('does not on a push-only binding, which has no business pulling', () => {
|
|
const p = plan({
|
|
items: [failedPull()],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v1' })],
|
|
local: [],
|
|
direction: 'push',
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).not.toContain('pull_update');
|
|
});
|
|
|
|
it('leaves a rejected type alone: that answer does not change by trying again', () => {
|
|
const p = plan({
|
|
items: [failedPull({ state: 'rejected_type' })],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v1' })],
|
|
local: [],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).not.toContain('pull_update');
|
|
});
|
|
|
|
it('leaves an oversized document alone for the same reason', () => {
|
|
const p = plan({
|
|
items: [failedPull({ state: 'too_large' })],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v1' })],
|
|
local: [],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).not.toContain('pull_update');
|
|
});
|
|
|
|
it('does try a rejected one again once the document upstream changes', () => {
|
|
const p = plan({
|
|
items: [failedPull({ state: 'rejected_type' })],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v2' })],
|
|
local: [],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toContain('pull_update');
|
|
});
|
|
|
|
it('gives up with the rest once the attempts are spent', () => {
|
|
const p = plan({
|
|
items: [failedPull({ attempts: 6 })],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v1' })],
|
|
local: [],
|
|
maxAttempts: 6,
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('leaves a healthy pairing on touch, not on a pointless re-download', () => {
|
|
const p = plan({
|
|
items: [item({ remoteId: 'r1', remoteVersion: 'v1' })],
|
|
remote: [remote({ remoteId: 'r1', remoteVersion: 'v1' })],
|
|
local: [local({ fileId: 1 })],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['touch']);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Names are compared through the same sanitiser the local one went through.
|
|
*
|
|
* A provider name TREK had to clean up (a slash, a control character, a
|
|
* leading dot) otherwise reads as "TREK renamed this document" on the very
|
|
* next run, and the provider's copy is renamed to the cleaned version without
|
|
* anybody asking for it.
|
|
*/
|
|
describe('planReconcile > a provider name TREK had to clean up', () => {
|
|
it('does not read the cleaning as a local rename', () => {
|
|
const p = plan({
|
|
items: [item({ remoteName: 'a/b.pdf', contentSha256: 'aaa' })],
|
|
remote: [remote({ remoteId: 'r1', name: 'a/b.pdf', remoteVersion: 'v1' })],
|
|
local: [local({ fileId: 1, name: 'b.pdf' })], // what sanitizeIncomingName made of it
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['touch']);
|
|
});
|
|
|
|
it('still follows a real rename at the provider', () => {
|
|
const p = plan({
|
|
items: [item({ remoteName: 'b.pdf', contentSha256: 'aaa' })],
|
|
remote: [remote({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v1' })],
|
|
local: [local({ fileId: 1, name: 'b.pdf' })],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['rename_local']);
|
|
});
|
|
|
|
it('takes a new name into TREK the way a download would have written it, so it stays', () => {
|
|
// Taken over raw, the next run compared the cleaned provider name with the
|
|
// raw one in TREK, read the difference as a rename made in TREK, and sent
|
|
// the name back upstream: a Paperless title with a colon, renamed to itself.
|
|
const renamed = remote({ remoteId: 'r1', name: 'Hotel: Kyoto.pdf', remoteVersion: 'v1' });
|
|
const first = plan({
|
|
items: [item({ remoteName: 'b.pdf' })],
|
|
remote: [renamed],
|
|
local: [local({ fileId: 1, name: 'b.pdf' })],
|
|
});
|
|
expect(first.actions).toEqual([{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'Hotel_ Kyoto.pdf' }]);
|
|
|
|
const second = plan({
|
|
items: [item({ remoteName: 'Hotel_ Kyoto.pdf' })],
|
|
remote: [renamed],
|
|
local: [local({ fileId: 1, name: 'Hotel_ Kyoto.pdf' })],
|
|
});
|
|
expect(second.actions).toEqual([{ kind: 'touch', itemId: 10, remote: renamed }]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TREK's own name with a character the cleaning replaces.
|
|
*
|
|
* An upload keeps the name the browser sent, and on Linux and macOS that may
|
|
* be `Zugticket 08:15.pdf`. A push stores it as the agreed name, and the next
|
|
* run compared the raw TREK name with the cleaned agreed name, which never
|
|
* match: Paperless was sent the same title on every run, and on Nextcloud,
|
|
* which cleans the colon its own way, the row sat in a conflict that "keep
|
|
* TREK" could never settle, because the three names came out the same again.
|
|
*/
|
|
describe('planReconcile > a TREK name the cleaning would change', () => {
|
|
const pushed = item({ remoteName: 'Zugticket 08:15.pdf', contentSha256: 'aaa', pushedSha256: 'aaa' });
|
|
const mine = local({ fileId: 1, name: 'Zugticket 08:15.pdf' });
|
|
|
|
it('does not read the cleaning as a rename made in TREK', () => {
|
|
// Paperless keeps the title as it was sent, so the listing carries the colon.
|
|
const p = plan({ items: [pushed], remote: [remote({ name: 'Zugticket 08:15.pdf' })], local: [mine] });
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['touch']);
|
|
});
|
|
|
|
it('follows the name the store made of it where the store cleans differently', () => {
|
|
// Nextcloud stores a dash, which is the store's rename to follow, once.
|
|
const stored = remote({ name: 'Zugticket 08-15.pdf' });
|
|
const first = plan({ items: [pushed], remote: [stored], local: [mine] });
|
|
expect(first.actions).toEqual([{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'Zugticket 08-15.pdf' }]);
|
|
|
|
const second = plan({
|
|
items: [item({ remoteName: 'Zugticket 08-15.pdf', contentSha256: 'aaa', pushedSha256: 'aaa' })],
|
|
remote: [stored],
|
|
local: [local({ fileId: 1, name: 'Zugticket 08-15.pdf' })],
|
|
});
|
|
expect(second.actions).toEqual([{ kind: 'touch', itemId: 10, remote: stored }]);
|
|
});
|
|
|
|
it('sends nothing upstream on a push-only binding either', () => {
|
|
const p = plan({
|
|
items: [pushed],
|
|
remote: [remote({ name: 'Zugticket 08:15.pdf' })],
|
|
local: [mine],
|
|
direction: 'push',
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['touch']);
|
|
});
|
|
|
|
it('still sends a real rename made in TREK', () => {
|
|
const p = plan({
|
|
items: [pushed],
|
|
remote: [remote({ name: 'Zugticket 08:15.pdf' })],
|
|
local: [local({ fileId: 1, name: 'Zugticket 09:15.pdf' })],
|
|
});
|
|
expect(p.actions).toEqual([{ kind: 'rename_remote', itemId: 10, remoteId: 'r1', name: 'Zugticket 09:15.pdf' }]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A rename upstream where the id is the path, which is Synology FileStation.
|
|
*
|
|
* It bounced. The upstream-only loop re-paired the renamed copy with a `touch`,
|
|
* and `touch` wrote the provider's new name into the rename arbiter while the
|
|
* trip file kept the old one: the next run read that as a rename made in TREK
|
|
* and renamed the provider's copy back. The same run had flagged the pairing
|
|
* missing too, because the vanished list was drawn up before the re-pairing.
|
|
* Observed: run 1 [touch b.pdf, mark_remote_missing], run 2 [rename_remote a.pdf].
|
|
*/
|
|
describe('planReconcile > a rename upstream where the id is the path', () => {
|
|
const at = '2026-09-18T10:00:00Z';
|
|
const paired = (over: Partial<SyncItemState> = {}) =>
|
|
item({
|
|
remoteId: '/trek/a.pdf',
|
|
remoteVersion: 'va',
|
|
remoteName: 'a.pdf',
|
|
contentSha256: 'hash-a',
|
|
remoteSize: 1024,
|
|
remoteModifiedAt: at,
|
|
...over,
|
|
});
|
|
const listed = (over: Partial<RemoteDocument> = {}) =>
|
|
remote({
|
|
remoteId: '/trek/b.pdf',
|
|
name: 'b.pdf',
|
|
remoteVersion: 'vb',
|
|
contentHash: 'hash-a',
|
|
size: 1024,
|
|
remoteModifiedAt: at,
|
|
...over,
|
|
});
|
|
// No local hash, as in production: TREK has no way to change a file's bytes,
|
|
// so the service never reports one (see loadLocalDocuments).
|
|
const file = (over: Partial<LocalDocument> = {}) => local({ name: 'a.pdf', sha256: null, ...over });
|
|
const pathIds = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) =>
|
|
plan({ stableRemoteIds: false, ...over });
|
|
const kinds = (p: ReturnType<typeof plan>) => p.actions.map((a) => a.kind);
|
|
|
|
it('takes the new name into TREK and leaves it there on the next run', () => {
|
|
const first = pathIds({ items: [paired()], remote: [listed()], local: [file()] });
|
|
expect(first.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 10, remote: listed() },
|
|
{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'b.pdf' },
|
|
]);
|
|
|
|
// What the executor leaves behind: the pairing on the new path, the agreed
|
|
// name moved on by rename_local, the trip file renamed.
|
|
const second = pathIds({
|
|
items: [paired({ remoteId: '/trek/b.pdf', remoteVersion: 'vb', remoteName: 'b.pdf' })],
|
|
remote: [listed()],
|
|
local: [file({ name: 'b.pdf' })],
|
|
});
|
|
expect(second.actions).toEqual([{ kind: 'touch', itemId: 10, remote: listed() }]);
|
|
});
|
|
|
|
it('ends the way it ends where ids are stable, apart from moving the pairing', () => {
|
|
const pathKeyed = pathIds({ items: [paired()], remote: [listed()], local: [file()] });
|
|
const stable = plan({
|
|
items: [paired({ remoteId: '/trek/b.pdf', remoteVersion: 'vb' })],
|
|
remote: [listed()],
|
|
local: [file()],
|
|
});
|
|
expect(pathKeyed.actions.filter((a) => a.kind !== 'relocate')).toEqual(stable.actions);
|
|
});
|
|
|
|
it('does not flag the renamed copy as missing', () => {
|
|
const p = pathIds({ items: [paired()], remote: [listed()], local: [file()] });
|
|
expect(p.missingCount).toBe(0);
|
|
expect(kinds(p)).not.toContain('mark_remote_missing');
|
|
});
|
|
|
|
it('does not read a whole folder moved in one go as a mass deletion', () => {
|
|
const rows = Array.from({ length: 10 }, (_, i) =>
|
|
paired({
|
|
id: 100 + i,
|
|
fileId: 100 + i,
|
|
remoteId: `/trek/${i}.pdf`,
|
|
remoteName: `${i}.pdf`,
|
|
contentSha256: `hash-${i}`,
|
|
}),
|
|
);
|
|
const p = pathIds({
|
|
items: rows,
|
|
remote: rows.map((_, i) =>
|
|
listed({ remoteId: `/trek/2026/${i}.pdf`, name: `${i}.pdf`, contentHash: `hash-${i}` }),
|
|
),
|
|
local: rows.map((r, i) => file({ fileId: r.fileId as number, name: `${i}.pdf` })),
|
|
});
|
|
expect(p.massDeleteGuardTripped).toBe(false);
|
|
expect(p.missingCount).toBe(0);
|
|
expect(kinds(p).filter((k) => k === 'relocate')).toHaveLength(10);
|
|
expect(kinds(p)).not.toContain('pull');
|
|
});
|
|
|
|
it('follows a copy moved to another folder and leaves its name alone', () => {
|
|
const moved = listed({ remoteId: '/trek/receipts/a.pdf', name: 'a.pdf' });
|
|
const p = pathIds({ items: [paired()], remote: [moved], local: [file()] });
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 10, remote: moved },
|
|
{ kind: 'touch', itemId: 10, remote: moved },
|
|
]);
|
|
});
|
|
|
|
it('recognises the copy by size and time where the listing carries no hash', () => {
|
|
// Synology offers no sha256 at all, so this is the case that actually
|
|
// happens there; the hash-only version of this never fired.
|
|
const p = pathIds({ items: [paired()], remote: [listed({ contentHash: null })], local: [file()] });
|
|
expect(kinds(p)).toEqual(['relocate', 'rename_local']);
|
|
});
|
|
|
|
it('does not follow into a copy with other bytes, whatever its size and time', () => {
|
|
const p = pathIds({ items: [paired()], remote: [listed({ contentHash: 'hash-other' })], local: [file()] });
|
|
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
|
});
|
|
|
|
it('does not follow into a copy that was edited as well', () => {
|
|
const edited = listed({ contentHash: null, remoteModifiedAt: '2026-09-19T08:00:00Z' });
|
|
const p = pathIds({ items: [paired()], remote: [edited], local: [file()] });
|
|
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
|
});
|
|
|
|
it('does not guess from size and time it never recorded', () => {
|
|
const p = pathIds({
|
|
items: [paired({ remoteSize: null, remoteModifiedAt: null })],
|
|
remote: [listed({ contentHash: null })],
|
|
local: [file()],
|
|
});
|
|
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
|
});
|
|
|
|
it('pairs nothing when two copies of the same bytes were renamed at once', () => {
|
|
// Either way round fits the content, and the crosswise one puts each name
|
|
// on the other trip file. A missing flag and a second download can be seen
|
|
// and undone; a swapped pairing cannot be seen at all.
|
|
const p = pathIds({
|
|
items: [
|
|
paired({ id: 1, fileId: 1, remoteId: '/trek/a1.pdf', remoteName: 'a1.pdf' }),
|
|
paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' }),
|
|
],
|
|
remote: [
|
|
listed({ remoteId: '/trek/b1.pdf', name: 'b1.pdf' }),
|
|
listed({ remoteId: '/trek/b2.pdf', name: 'b2.pdf' }),
|
|
],
|
|
local: [file({ fileId: 1, name: 'a1.pdf' }), file({ fileId: 2, name: 'a2.pdf' })],
|
|
});
|
|
expect(kinds(p).sort()).toEqual(['mark_remote_missing', 'mark_remote_missing', 'pull', 'pull']);
|
|
});
|
|
|
|
it('tells two copies of the same bytes apart when they were moved and kept their names', () => {
|
|
const one = listed({ remoteId: '/trek/2026/a1.pdf', name: 'a1.pdf' });
|
|
const two = listed({ remoteId: '/trek/2026/a2.pdf', name: 'a2.pdf' });
|
|
const p = pathIds({
|
|
items: [
|
|
paired({ id: 1, fileId: 1, remoteId: '/trek/a1.pdf', remoteName: 'a1.pdf' }),
|
|
paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' }),
|
|
],
|
|
remote: [two, one],
|
|
local: [file({ fileId: 1, name: 'a1.pdf' }), file({ fileId: 2, name: 'a2.pdf' })],
|
|
});
|
|
expect(p.actions.filter((a) => a.kind === 'relocate')).toEqual([
|
|
{ kind: 'relocate', itemId: 1, remote: one },
|
|
{ kind: 'relocate', itemId: 2, remote: two },
|
|
]);
|
|
expect(kinds(p).filter((k) => k !== 'relocate')).toEqual(['touch', 'touch']);
|
|
});
|
|
|
|
it('follows the one of two identical copies that was renamed and leaves the other', () => {
|
|
const kept = listed({ remoteId: '/trek/a2.pdf', name: 'a2.pdf', remoteVersion: 'va' });
|
|
const renamed = listed({ remoteId: '/trek/b1.pdf', name: 'b1.pdf' });
|
|
const p = pathIds({
|
|
items: [
|
|
paired({ id: 1, fileId: 1, remoteId: '/trek/a1.pdf', remoteName: 'a1.pdf' }),
|
|
paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' }),
|
|
],
|
|
remote: [kept, renamed],
|
|
local: [file({ fileId: 1, name: 'a1.pdf' }), file({ fileId: 2, name: 'a2.pdf' })],
|
|
});
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 1, remote: renamed },
|
|
{ kind: 'rename_local', itemId: 1, fileId: 1, name: 'b1.pdf' },
|
|
{ kind: 'touch', itemId: 2, remote: kept },
|
|
]);
|
|
});
|
|
|
|
it('finds a copy again after TREK renamed it upstream', () => {
|
|
// The adapter cannot say what path a rename produced, so the row keeps the
|
|
// old one (and the name rename_remote agreed) until the next listing.
|
|
const p = pathIds({
|
|
items: [paired({ remoteName: 'b.pdf', remoteVersion: 'vb' })],
|
|
remote: [listed({ contentHash: null })],
|
|
local: [file({ name: 'b.pdf' })],
|
|
});
|
|
expect(kinds(p)).toEqual(['relocate', 'touch']);
|
|
});
|
|
|
|
it('keeps a document deleted in TREK deleted when its copy is renamed upstream', () => {
|
|
// Where ids are stable a pairing already acted on stays put. Here the
|
|
// renamed copy would otherwise come back as a new document.
|
|
const p = pathIds({
|
|
items: [paired({ state: 'local_deleted' })],
|
|
remote: [listed()],
|
|
local: [file({ deletedAt: '2026-09-18 08:00:00' })],
|
|
});
|
|
expect(p.actions).toEqual([{ kind: 'relocate', itemId: 10, remote: listed() }]);
|
|
});
|
|
|
|
describe('next to a twin TREK binned', () => {
|
|
// Deleting one of two identical files in TREK is the usual way two rows
|
|
// come to share content. The binned one used to count as a candidate, so a
|
|
// rename of the live one was a tie and came back as missing plus new.
|
|
const binned = (over: Partial<SyncItemState> = {}) =>
|
|
paired({
|
|
id: 1,
|
|
fileId: 1,
|
|
remoteId: '/trek/a1.pdf',
|
|
remoteName: 'a1.pdf',
|
|
state: 'local_deleted',
|
|
remoteTrashedAt: '2026-09-18 08:05:00',
|
|
...over,
|
|
});
|
|
const live = paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' });
|
|
const renamed = listed({ remoteId: '/trek/b.pdf', name: 'b.pdf', contentHash: null });
|
|
|
|
it('follows the rename of the live one', () => {
|
|
const p = pathIds({
|
|
items: [binned(), live],
|
|
remote: [renamed],
|
|
local: [
|
|
file({ fileId: 1, name: 'a1.pdf', deletedAt: '2026-09-18 08:00:00' }),
|
|
file({ fileId: 2, name: 'a2.pdf' }),
|
|
],
|
|
});
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 2, remote: renamed },
|
|
{ kind: 'rename_local', itemId: 2, fileId: 2, name: 'b.pdf' },
|
|
]);
|
|
expect(p.missingCount).toBe(0);
|
|
});
|
|
|
|
it('does so once the binned one is purged as well', () => {
|
|
const p = pathIds({
|
|
items: [binned({ fileId: null }), live],
|
|
remote: [renamed],
|
|
local: [file({ fileId: 2, name: 'a2.pdf' })],
|
|
});
|
|
expect(kinds(p)).toEqual(['relocate', 'rename_local']);
|
|
expect(p.actions[0]).toMatchObject({ itemId: 2 });
|
|
});
|
|
|
|
it('still recognises the binned copy when it comes back elsewhere on its own', () => {
|
|
const back = listed({ remoteId: '/trek/restored/a1.pdf', name: 'a1.pdf', contentHash: null });
|
|
const p = pathIds({
|
|
items: [binned()],
|
|
remote: [back],
|
|
local: [file({ fileId: 1, name: 'a1.pdf', deletedAt: '2026-09-18 08:00:00' })],
|
|
});
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 1, remote: back },
|
|
{ kind: 'remote_restored', itemId: 1 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
it('leaves a shelved row where it is: that waits for a person', () => {
|
|
const p = pathIds({
|
|
items: [paired({ state: 'error', attempts: 6, fileId: null, contentSha256: null })],
|
|
remote: [listed({ contentHash: null })],
|
|
local: [],
|
|
});
|
|
expect(kinds(p)).not.toContain('relocate');
|
|
});
|
|
|
|
it('follows nothing where ids are stable: a new id there is a new document', () => {
|
|
const p = plan({ items: [paired()], remote: [listed()], local: [file()] });
|
|
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
|
});
|
|
|
|
it('follows nothing under an unchanged upstream, which lists nothing to follow into', () => {
|
|
const p = pathIds({ items: [paired()], remote: [listed()], local: [file()], remoteUnchanged: true });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Both sides renamed a document whose bytes agree: only the name is in dispute.
|
|
*
|
|
* `trek_wins` used to settle it like changed bytes, with a push_update. That
|
|
* re-sent identical bytes, and where the provider replaces in place (WebDAV,
|
|
* and Synology once renames there were followed) the upload kept the
|
|
* provider's name: the next run read it as a rename made upstream and renamed
|
|
* TREK's file to it. Observed: run 1 [relocate, push_update], run 2 [rename_local].
|
|
*/
|
|
describe('planReconcile > both sides renamed', () => {
|
|
const at = '2026-09-18T10:00:00Z';
|
|
const paired = item({ remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteSize: 1024, remoteModifiedAt: at });
|
|
const theirs = remote({ remoteId: '/trek/c.pdf', name: 'c.pdf', remoteModifiedAt: at });
|
|
const mine = local({ name: 'b.pdf', sha256: null });
|
|
const pathIds = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) =>
|
|
plan({ stableRemoteIds: false, items: [paired], remote: [theirs], local: [mine], ...over });
|
|
|
|
it("carries TREK's name upstream when TREK wins, and sends no bytes", () => {
|
|
const p = pathIds({ conflictPolicy: 'trek_wins' });
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 10, remote: theirs },
|
|
{ kind: 'rename_remote', itemId: 10, remoteId: '/trek/c.pdf', name: 'b.pdf' },
|
|
]);
|
|
});
|
|
|
|
it("takes the provider's name into TREK when the provider wins, and fetches nothing", () => {
|
|
const p = pathIds({ conflictPolicy: 'provider_wins' });
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 10, remote: theirs },
|
|
{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'c.pdf' },
|
|
]);
|
|
});
|
|
|
|
it('does the same where ids are stable', () => {
|
|
const stable = (conflictPolicy: 'trek_wins' | 'provider_wins') =>
|
|
plan({
|
|
conflictPolicy,
|
|
items: [item({ remoteName: 'old.pdf' })],
|
|
remote: [remote({ name: 'theirs.pdf' })],
|
|
local: [local({ name: 'mine.pdf' })],
|
|
}).actions;
|
|
expect(stable('trek_wins')).toEqual([{ kind: 'rename_remote', itemId: 10, remoteId: 'r1', name: 'mine.pdf' }]);
|
|
expect(stable('provider_wins')).toEqual([{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'theirs.pdf' }]);
|
|
});
|
|
|
|
it('parks it when the direction forbids what the winner would need', () => {
|
|
expect(pathIds({ conflictPolicy: 'trek_wins', direction: 'pull' }).actions.map((a) => a.kind)).toEqual([
|
|
'relocate',
|
|
'conflict',
|
|
]);
|
|
expect(pathIds({ conflictPolicy: 'provider_wins', direction: 'push' }).actions.map((a) => a.kind)).toEqual([
|
|
'relocate',
|
|
'conflict',
|
|
]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A deletion in TREK is acted on once.
|
|
*
|
|
* Both branches used to plan `local_deleted` for as long as the file sat in
|
|
* TREK's trash, so the policy was applied again on every run. Under `unlink`
|
|
* that was invisible until the binding was switched to `trash`: the next run
|
|
* then binned every document ever deleted in TREK.
|
|
*/
|
|
describe('planReconcile > a deletion TREK already acted on', () => {
|
|
const settled = (over: Partial<SyncItemState> = {}) =>
|
|
item({ id: 50, fileId: 5, remoteId: 'r5', state: 'local_deleted', ...over });
|
|
const inTrash = local({ fileId: 5, deletedAt: '2026-09-18 08:00:00' });
|
|
|
|
it('plans nothing more while the file sits in the trash', () => {
|
|
const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5' })], local: [inTrash] });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('plans nothing more under an unchanged upstream either', () => {
|
|
const p = plan({ items: [settled()], remote: [], local: [inTrash], remoteUnchanged: true });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('does not download the document again once the file is purged', () => {
|
|
// file_id is ON DELETE SET NULL, and a row without a file used to read as
|
|
// a download that never landed.
|
|
const p = plan({ items: [settled({ fileId: null })], remote: [remote({ remoteId: 'r5' })], local: [] });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('reads a file purged before any run saw it deleted as a deletion', () => {
|
|
const p = plan({ items: [item({ fileId: null, state: 'synced' })], remote: [remote()], local: [] });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
|
});
|
|
|
|
it('does the same under an unchanged upstream', () => {
|
|
const p = plan({ items: [item({ fileId: null, state: 'synced' })], remote: [], local: [], remoteUnchanged: true });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
|
});
|
|
|
|
it('leaves a copy already on record as gone alone under an unchanged upstream', () => {
|
|
// Binning it would record the gap as TREK's doing, and a restore would
|
|
// later upload a document somebody else deleted.
|
|
const p = plan({
|
|
items: [item({ state: 'remote_missing', remoteMissingAt: '2026-09-17 08:00:00' })],
|
|
remote: [],
|
|
local: [local({ deletedAt: '2026-09-18 08:00:00' })],
|
|
remoteUnchanged: true,
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A copy on record as gone whose TREK copy was then purged.
|
|
*
|
|
* The row waited for a person to decide about the TREK copy, and nothing
|
|
* closed it once they had: the pair loop never sees a row with no listing
|
|
* entry, the unchanged branch skips the state by name, and the purge left
|
|
* `file_id` NULL with the row still counting under "Needs a look" for good.
|
|
* With nothing left on either side, the row closes, and without asking the
|
|
* provider to bin a copy that is not there.
|
|
*/
|
|
it('closes a row whose copies are gone on both sides, without binning anything', () => {
|
|
const gone = item({ state: 'remote_missing', remoteMissingAt: '2026-09-17 08:00:00', fileId: null });
|
|
const p = plan({ items: [gone], remote: [], local: [] });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1', remoteGone: true }]);
|
|
expect(p.missingCount).toBe(0);
|
|
});
|
|
|
|
it('does the same under an unchanged upstream', () => {
|
|
const gone = item({ state: 'remote_missing', remoteMissingAt: '2026-09-17 08:00:00', fileId: null });
|
|
const p = plan({ items: [gone], remote: [], local: [], remoteUnchanged: true });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1', remoteGone: true }]);
|
|
});
|
|
|
|
it('treats a purged file as an ordinary deletion once the copy is listed again', () => {
|
|
const gone = item({ state: 'remote_missing', remoteMissingAt: '2026-09-17 08:00:00', fileId: null });
|
|
const p = plan({ items: [gone], remote: [remote()], local: [] });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
|
});
|
|
|
|
it('notices when the copy TREK binned is listed again', () => {
|
|
const p = plan({
|
|
items: [settled({ remoteTrashedAt: '2026-09-18 08:05:00' })],
|
|
remote: [remote({ remoteId: 'r5' })],
|
|
local: [inTrash],
|
|
});
|
|
expect(p.actions).toEqual([{ kind: 'remote_restored', itemId: 50 }]);
|
|
});
|
|
|
|
/**
|
|
* A failed update keeps the file it had and sets `error`. Deleted and purged
|
|
* while the row was held back, it read as a first download that never
|
|
* landed, and the next attempt ("Sync now" clears the counter) brought the
|
|
* deleted document straight back.
|
|
*/
|
|
describe('on a row that failed an update first', () => {
|
|
const failedUpdate = (over: Partial<SyncItemState> = {}) =>
|
|
item({ fileId: null, state: 'error', attempts: 2, contentSha256: 'aaa', ...over });
|
|
|
|
it('reads the purge as a deletion', () => {
|
|
const p = plan({ items: [failedUpdate()], remote: [remote()], local: [] });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
|
});
|
|
|
|
it('does the same under an unchanged upstream', () => {
|
|
const p = plan({ items: [failedUpdate()], remote: [], local: [], remoteUnchanged: true });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
|
});
|
|
|
|
it('waits out the backoff like any other action for the row', () => {
|
|
const p = plan({
|
|
items: [failedUpdate({ nextAttemptAt: '2030-01-01 00:00:00' })],
|
|
remote: [remote()],
|
|
local: [],
|
|
now: '2026-09-19 08:00:00',
|
|
});
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('treats a row waiting on a conflict answer the same way', () => {
|
|
const p = plan({ items: [failedUpdate({ state: 'pending', attempts: 0 })], remote: [remote()], local: [] });
|
|
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
|
});
|
|
|
|
it('still retries a first download, which never agreed on a hash', () => {
|
|
const p = plan({ items: [failedUpdate({ contentSha256: null })], remote: [remote()], local: [] });
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['pull_update']);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A file taken back out of TREK's trash.
|
|
*
|
|
* Which way the row goes depends on what became of the provider copy while
|
|
* TREK's sat in the bin: still there, binned by TREK itself, or deleted by
|
|
* somebody else.
|
|
*/
|
|
describe('planReconcile > a file taken back out of TREK trash', () => {
|
|
const settled = (over: Partial<SyncItemState> = {}) =>
|
|
item({ id: 50, fileId: 5, remoteId: 'r5', state: 'local_deleted', ...over });
|
|
const binnedByTrek = (over: Partial<SyncItemState> = {}) =>
|
|
settled({ remoteTrashedAt: '2026-09-18 08:05:00', ...over });
|
|
const back = local({ fileId: 5 });
|
|
|
|
it('resumes the pairing when the provider copy stayed', () => {
|
|
const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5' })], local: [back] });
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['local_restored', 'touch']);
|
|
});
|
|
|
|
it('brings down an edit made upstream in the meantime, as for any synced row', () => {
|
|
const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5', remoteVersion: 'v2' })], local: [back] });
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['local_restored', 'pull_update']);
|
|
});
|
|
|
|
it('flags a copy somebody else deleted meanwhile instead of uploading it', () => {
|
|
const p = plan({ items: [settled()], remote: [], local: [back] });
|
|
expect(p.actions).toEqual([{ kind: 'local_restored', itemId: 50, missing: true }]);
|
|
expect(p.missingCount).toBe(1);
|
|
});
|
|
|
|
it('waits under an unchanged upstream, which shows nothing of the copy', () => {
|
|
// Resumed as synced here, a copy that was long gone counted as one that
|
|
// vanished on the next real listing, and could trip the guard with it.
|
|
const p = plan({ items: [settled()], remote: [], local: [back], remoteUnchanged: true });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('waits on a truncated listing that does not show the copy', () => {
|
|
const p = plan({ items: [settled()], remote: [], local: [back], remoteTruncated: true });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('resumes from a truncated listing that does show the copy', () => {
|
|
const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5' })], local: [back], remoteTruncated: true });
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['local_restored', 'touch']);
|
|
});
|
|
|
|
it('uploads it again when TREK was the one that binned the copy', () => {
|
|
const p = plan({ items: [binnedByTrek()], remote: [], local: [back] });
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'detach', itemId: 50 },
|
|
{ kind: 'push', local: back, itemId: 50, remoteId: null },
|
|
]);
|
|
});
|
|
|
|
it('does so under an unchanged upstream too', () => {
|
|
const p = plan({ items: [binnedByTrek()], remote: [], local: [back], remoteUnchanged: true });
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['detach', 'push']);
|
|
});
|
|
|
|
it('is not held back by the echo guard: nothing about it is a remote change', () => {
|
|
const p = plan({
|
|
items: [binnedByTrek({ pushedSha256: 'aaa' })],
|
|
remote: [],
|
|
local: [local({ fileId: 5, sha256: 'aaa' })],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['detach', 'push']);
|
|
});
|
|
|
|
it('waits on a pull-only binding, which cannot upload', () => {
|
|
const p = plan({ items: [binnedByTrek()], remote: [], local: [back], direction: 'pull' });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('waits on a truncated listing, where a gap proves nothing', () => {
|
|
const p = plan({ items: [binnedByTrek()], remote: [], local: [back], remoteTruncated: true });
|
|
expect(p.actions).toEqual([]);
|
|
});
|
|
|
|
it('resumes instead when the binned copy was restored at the provider as well', () => {
|
|
const p = plan({ items: [binnedByTrek()], remote: [remote({ remoteId: 'r5' })], local: [back] });
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['local_restored', 'touch']);
|
|
});
|
|
|
|
it('reads many restores after TREK binned them as uploads, not as a mass deletion', () => {
|
|
const rows = Array.from({ length: 10 }, (_, i) =>
|
|
binnedByTrek({ id: 100 + i, fileId: 100 + i, remoteId: `r${100 + i}` }),
|
|
);
|
|
const p = plan({ items: rows, remote: [], local: rows.map((r) => local({ fileId: r.fileId as number })) });
|
|
expect(p.massDeleteGuardTripped).toBe(false);
|
|
expect(p.actions.filter((a) => a.kind === 'detach')).toHaveLength(10);
|
|
expect(p.actions.filter((a) => a.kind === 'push')).toHaveLength(10);
|
|
});
|
|
|
|
it('does not read many restores whose copies are gone as a mass deletion', () => {
|
|
// Nothing was recorded about those copies while TREK's files sat in the
|
|
// bin, so they may have been gone for weeks: the listing did not shrink.
|
|
// Counted as vanished, they tripped the guard, the run was dropped before
|
|
// the restores were written, and every run after it planned the same.
|
|
const rows = Array.from({ length: 10 }, (_, i) =>
|
|
settled({ id: 100 + i, fileId: 100 + i, remoteId: `r${100 + i}` }),
|
|
);
|
|
const p = plan({ items: rows, remote: [], local: rows.map((r) => local({ fileId: r.fileId as number })) });
|
|
expect(p.massDeleteGuardTripped).toBe(false);
|
|
expect(p.actions).toEqual(rows.map((r) => ({ kind: 'local_restored', itemId: r.id, missing: true })));
|
|
expect(p.missingCount).toBe(10);
|
|
});
|
|
|
|
describe('beside a binding that is otherwise in step', () => {
|
|
const synced = Array.from({ length: 6 }, (_, i) => item({ id: 200 + i, fileId: 200 + i, remoteId: `s${i}` }));
|
|
const restored = Array.from({ length: 5 }, (_, i) => settled({ id: 300 + i, fileId: 300 + i, remoteId: `g${i}` }));
|
|
const files = [...synced, ...restored].map((r) => local({ fileId: r.fileId as number }));
|
|
const newUpstream = remote({ remoteId: 'fresh', name: 'visa.pdf' });
|
|
|
|
it('leaves the guard alone and syncs the rest as usual', () => {
|
|
const p = plan({
|
|
items: [...synced, ...restored],
|
|
remote: [...synced.map((s) => remote({ remoteId: s.remoteId as string })), newUpstream],
|
|
local: files,
|
|
});
|
|
expect(p.massDeleteGuardTripped).toBe(false);
|
|
expect(p.actions.filter((a) => a.kind === 'local_restored')).toEqual(
|
|
restored.map((r) => ({ kind: 'local_restored', itemId: r.id, missing: true })),
|
|
);
|
|
expect(p.actions).toContainEqual({ kind: 'pull', remote: newUpstream, itemId: null });
|
|
expect(p.actions.map((a) => a.kind)).not.toContain('mark_remote_missing');
|
|
});
|
|
|
|
it('still trips the guard when the synced rows themselves vanish', () => {
|
|
const p = plan({ items: synced, remote: [remote({ remoteId: 's0' })], local: files });
|
|
expect(p.massDeleteGuardTripped).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('resumes when the binned copy was restored at the provider under a new path', () => {
|
|
// Detached first, the row lost its id before the move could be followed:
|
|
// the restored copy came down as a new document and TREK's file went up as
|
|
// another, two of each.
|
|
const p = plan({
|
|
stableRemoteIds: false,
|
|
items: [
|
|
binnedByTrek({
|
|
remoteId: '/trek/a.pdf',
|
|
remoteName: 'a.pdf',
|
|
remoteSize: 1024,
|
|
remoteModifiedAt: '2026-09-18T10:00:00Z',
|
|
}),
|
|
],
|
|
remote: [remote({ remoteId: '/trek/a restored.pdf', name: 'a restored.pdf' })],
|
|
local: [local({ fileId: 5, name: 'a.pdf', sha256: null })],
|
|
});
|
|
expect(p.actions.map((a) => a.kind)).toEqual(['relocate', 'local_restored', 'rename_local']);
|
|
});
|
|
|
|
it('follows a copy moved while the file was in the bin, when TREK did not bin it', () => {
|
|
const moved = remote({ remoteId: '/trek/2026/a.pdf', name: 'a.pdf' });
|
|
const p = plan({
|
|
stableRemoteIds: false,
|
|
items: [
|
|
settled({
|
|
remoteId: '/trek/a.pdf',
|
|
remoteName: 'a.pdf',
|
|
remoteSize: 1024,
|
|
remoteModifiedAt: '2026-09-18T10:00:00Z',
|
|
}),
|
|
],
|
|
remote: [moved],
|
|
local: [local({ fileId: 5, name: 'a.pdf', sha256: null })],
|
|
});
|
|
expect(p.actions).toEqual([
|
|
{ kind: 'relocate', itemId: 50, remote: moved },
|
|
{ kind: 'local_restored', itemId: 50 },
|
|
{ kind: 'touch', itemId: 50, remote: moved },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('needsFullListing', () => {
|
|
const settled = (over: Partial<SyncItemState> = {}) =>
|
|
item({ id: 50, fileId: 5, remoteId: 'r5', state: 'local_deleted', ...over });
|
|
|
|
it('asks for one once a file whose copy TREK left alone is back', () => {
|
|
expect(needsFullListing([settled()], [local({ fileId: 5 })])).toBe(true);
|
|
});
|
|
|
|
it('does not while the file is still in the trash', () => {
|
|
expect(needsFullListing([settled()], [local({ fileId: 5, deletedAt: '2026-09-18 08:00:00' })])).toBe(false);
|
|
});
|
|
|
|
it('does not for a copy TREK binned: that gap is known, and the file goes up again', () => {
|
|
expect(needsFullListing([settled({ remoteTrashedAt: '2026-09-18 08:05:00' })], [local({ fileId: 5 })])).toBe(false);
|
|
});
|
|
|
|
it('does not once the file is purged', () => {
|
|
expect(needsFullListing([settled({ fileId: null })], [])).toBe(false);
|
|
});
|
|
|
|
it('does not for pairings that are simply in step', () => {
|
|
expect(needsFullListing([item()], [local()])).toBe(false);
|
|
});
|
|
});
|