feat(sync): selective push, remote deletion detection, and gcal fix

Problem: `push_pass` updated all remote-linked tasks unconditionally,
causing unnecessary API calls and potential clobbering of remote edits
made between syncs. `pull`/`sync` never noticed when a task disappeared
from remote. `update_event` omitted `transparency` that `create_event`
set. Failure counts were absent from sync log summaries.

Solution: Introduce `_gtasks_synced_at` in `_extra` — stamped after
every successful push/pull create or update — so `push_pass` skips
tasks unchanged since last sync. Add `detect_remote_deletions` to
unlink local tasks whose remote entry disappeared from a successfully
fetched list. Surface failures as `!N` in all sync logs and
`unlinked: N` for pull/sync. Add `transparency = 'transparent'` to
`update_event`. Cover new behaviour with 7 tests in `gtasks_spec.lua`.
This commit is contained in:
Barrett Ruth 2026-03-05 22:37:34 -05:00
parent e1923ff2e4
commit 3795999f3d
3 changed files with 280 additions and 17 deletions

View file

@ -176,3 +176,193 @@ describe('gtasks field conversion', function()
end)
end)
end)
describe('gtasks push_pass _gtasks_synced_at', function()
local helpers = require('spec.helpers')
local store_mod = require('pending.store')
local oauth = require('pending.sync.oauth')
local s
local orig_curl
before_each(function()
local dir = helpers.tmpdir()
s = store_mod.new(dir .. '/pending.json')
s:load()
orig_curl = oauth.curl_request
end)
after_each(function()
oauth.curl_request = orig_curl
end)
it('sets _gtasks_synced_at after push create', function()
local task =
s:add({ description = 'New task', status = 'pending', category = 'Work', priority = 0 })
oauth.curl_request = function(method, url, _headers, _body)
if method == 'POST' and url:find('/tasks$') then
return { id = 'gtask-new-1' }, nil
end
return {}, nil
end
local now_ts = '2026-03-05T10:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = {}
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_not_nil(task._extra)
assert.equals('2026-03-05T10:00:00Z', task._extra['_gtasks_synced_at'])
end)
it('skips update when modified <= _gtasks_synced_at', function()
local task =
s:add({ description = 'Existing task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-1',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-03-05T10:00:00Z',
}
task.modified = '2026-03-05T09:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-1'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_false(patch_called)
end)
it('pushes update when modified > _gtasks_synced_at', function()
local task =
s:add({ description = 'Changed task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-2',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-03-05T08:00:00Z',
}
task.modified = '2026-03-05T09:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-2'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_true(patch_called)
end)
it('pushes update when no _gtasks_synced_at (backwards compat)', function()
local task =
s:add({ description = 'Old task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-3',
_gtasks_list_id = 'list-1',
}
task.modified = '2026-01-01T00:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-3'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_true(patch_called)
end)
end)
describe('gtasks detect_remote_deletions', function()
local helpers = require('spec.helpers')
local store_mod = require('pending.store')
local s
before_each(function()
local dir = helpers.tmpdir()
s = store_mod.new(dir .. '/pending.json')
s:load()
end)
it('clears remote IDs when list was fetched but task ID is absent', function()
local task =
s:add({ description = 'Gone remote', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'old-remote-id',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-01-01T00:00:00Z',
}
local seen = {}
local fetched = { ['list-1'] = true }
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(1, unlinked)
assert.is_nil(task._extra)
assert.equals('2026-03-05T10:00:00Z', task.modified)
end)
it('leaves task untouched when its list fetch failed', function()
local task = s:add({
description = 'Unknown list task',
status = 'pending',
category = 'Work',
priority = 0,
})
task._extra = {
_gtasks_task_id = 'remote-id',
_gtasks_list_id = 'list-unfetched',
}
local seen = {}
local fetched = {}
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(0, unlinked)
assert.is_not_nil(task._extra)
assert.equals('remote-id', task._extra['_gtasks_task_id'])
end)
it('skips tasks with status == deleted', function()
local task =
s:add({ description = 'Deleted task', status = 'deleted', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-del',
_gtasks_list_id = 'list-1',
}
local seen = {}
local fetched = { ['list-1'] = true }
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(0, unlinked)
assert.is_not_nil(task._extra)
assert.equals('remote-del', task._extra['_gtasks_task_id'])
end)
end)