fix(docsync): stop a push from stealing another file's pairing

Papra answers a push of bytes it already holds with the document that has
them. Two trip files with the same content therefore pushed to one document,
and the unique index on (link_id, remote_id) aborted that run and every run
after it with a constraint error. The pairing now stays with the file that
owns it and the second file is recorded as a conflict.

Also in this pass:
- Paperless no longer adopts a same-bytes document that lacks the scope's tag
  when a consume outlives the poll budget; that document belongs to someone
  else's filing and delete-through would have trashed it.
- The pull path writes the file row, the retirement of the copy it replaces
  and the pairing in one transaction, and announces them only once committed.
- PATCH links stops accepting remoteRootId/remoteRootPath, which it read and
  silently dropped.
- The MCP sync tool clears shelved items before running, as the REST route
  does.
This commit is contained in:
Maurice
2026-09-19 00:44:43 +02:00
parent 7237a1881d
commit e6a5ca1521
11 changed files with 267 additions and 39 deletions
@@ -570,3 +570,31 @@ describe('planReconcile > a pairing whose download never landed', () => {
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']);
});
});