* feat(sync): unify Google auth under :Pending auth Problem: users had to run `:Pending gtasks auth` and `:Pending gcal auth` separately, producing two token files and two browser consents for the same Google account. Solution: introduce `oauth.google_client` with combined tasks + calendar scopes and a single `google_tokens.json`. Remove per-backend `auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth` that prompts with `vim.ui.select` and delegates to the shared client's `setup()` or `auth()` based on credential availability. * docs: update vimdoc for unified Google auth Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks auth` / `:Pending gcal auth` commands and separate token files, which no longer exist after the auth unification. Solution: add `:Pending auth` entry to COMMANDS and a new `*pending-google-auth*` section covering the shared PKCE flow, combined scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action tables and update all cross-references to use `:Pending auth`. * ci: format * 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`. * ci: formt
368 lines
11 KiB
Lua
368 lines
11 KiB
Lua
require('spec.helpers')
|
|
|
|
local gtasks = require('pending.sync.gtasks')
|
|
|
|
describe('gtasks field conversion', function()
|
|
describe('due date helpers', function()
|
|
it('converts date-only to RFC 3339', function()
|
|
assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15'))
|
|
end)
|
|
|
|
it('converts datetime to RFC 3339 (strips time)', function()
|
|
assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15T14:30'))
|
|
end)
|
|
|
|
it('strips RFC 3339 to date-only', function()
|
|
assert.equals('2026-03-15', gtasks._rfc3339_to_date('2026-03-15T00:00:00.000Z'))
|
|
end)
|
|
end)
|
|
|
|
describe('build_notes', function()
|
|
it('returns nil when no priority or recur', function()
|
|
assert.is_nil(gtasks._build_notes({ priority = 0, recur = nil }))
|
|
end)
|
|
|
|
it('encodes priority', function()
|
|
assert.equals('pri:1', gtasks._build_notes({ priority = 1, recur = nil }))
|
|
end)
|
|
|
|
it('encodes recur', function()
|
|
assert.equals('rec:weekly', gtasks._build_notes({ priority = 0, recur = 'weekly' }))
|
|
end)
|
|
|
|
it('encodes completion-mode recur with ! prefix', function()
|
|
assert.equals(
|
|
'rec:!daily',
|
|
gtasks._build_notes({ priority = 0, recur = 'daily', recur_mode = 'completion' })
|
|
)
|
|
end)
|
|
|
|
it('encodes both priority and recur', function()
|
|
assert.equals('pri:1 rec:weekly', gtasks._build_notes({ priority = 1, recur = 'weekly' }))
|
|
end)
|
|
end)
|
|
|
|
describe('parse_notes', function()
|
|
it('returns zeros/nils for nil input', function()
|
|
local pri, rec, mode = gtasks._parse_notes(nil)
|
|
assert.equals(0, pri)
|
|
assert.is_nil(rec)
|
|
assert.is_nil(mode)
|
|
end)
|
|
|
|
it('parses priority', function()
|
|
local pri = gtasks._parse_notes('pri:1')
|
|
assert.equals(1, pri)
|
|
end)
|
|
|
|
it('parses recur', function()
|
|
local _, rec = gtasks._parse_notes('rec:weekly')
|
|
assert.equals('weekly', rec)
|
|
end)
|
|
|
|
it('parses completion-mode recur', function()
|
|
local _, rec, mode = gtasks._parse_notes('rec:!daily')
|
|
assert.equals('daily', rec)
|
|
assert.equals('completion', mode)
|
|
end)
|
|
|
|
it('parses both priority and recur', function()
|
|
local pri, rec = gtasks._parse_notes('pri:1 rec:monthly')
|
|
assert.equals(1, pri)
|
|
assert.equals('monthly', rec)
|
|
end)
|
|
|
|
it('round-trips through build_notes', function()
|
|
local task = { priority = 1, recur = 'weekly', recur_mode = nil }
|
|
local notes = gtasks._build_notes(task)
|
|
local pri, rec = gtasks._parse_notes(notes)
|
|
assert.equals(1, pri)
|
|
assert.equals('weekly', rec)
|
|
end)
|
|
end)
|
|
|
|
describe('task_to_gtask', function()
|
|
it('maps description to title', function()
|
|
local body = gtasks._task_to_gtask({
|
|
description = 'Buy milk',
|
|
status = 'pending',
|
|
priority = 0,
|
|
})
|
|
assert.equals('Buy milk', body.title)
|
|
end)
|
|
|
|
it('maps pending status to needsAction', function()
|
|
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
|
|
assert.equals('needsAction', body.status)
|
|
end)
|
|
|
|
it('maps done status to completed', function()
|
|
local body = gtasks._task_to_gtask({ description = 'x', status = 'done', priority = 0 })
|
|
assert.equals('completed', body.status)
|
|
end)
|
|
|
|
it('converts due date to RFC 3339', function()
|
|
local body = gtasks._task_to_gtask({
|
|
description = 'x',
|
|
status = 'pending',
|
|
priority = 0,
|
|
due = '2026-03-15',
|
|
})
|
|
assert.equals('2026-03-15T00:00:00.000Z', body.due)
|
|
end)
|
|
|
|
it('omits due when nil', function()
|
|
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
|
|
assert.is_nil(body.due)
|
|
end)
|
|
|
|
it('includes notes when priority is set', function()
|
|
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 1 })
|
|
assert.equals('pri:1', body.notes)
|
|
end)
|
|
|
|
it('omits notes when no extra fields', function()
|
|
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
|
|
assert.is_nil(body.notes)
|
|
end)
|
|
end)
|
|
|
|
describe('gtask_to_fields', function()
|
|
it('maps title to description', function()
|
|
local fields = gtasks._gtask_to_fields({ title = 'Buy milk', status = 'needsAction' }, 'Work')
|
|
assert.equals('Buy milk', fields.description)
|
|
end)
|
|
|
|
it('maps category from list name', function()
|
|
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Personal')
|
|
assert.equals('Personal', fields.category)
|
|
end)
|
|
|
|
it('maps needsAction to pending', function()
|
|
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Work')
|
|
assert.equals('pending', fields.status)
|
|
end)
|
|
|
|
it('maps completed to done', function()
|
|
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'completed' }, 'Work')
|
|
assert.equals('done', fields.status)
|
|
end)
|
|
|
|
it('strips due date to YYYY-MM-DD', function()
|
|
local fields = gtasks._gtask_to_fields({
|
|
title = 'x',
|
|
status = 'needsAction',
|
|
due = '2026-03-15T00:00:00.000Z',
|
|
}, 'Work')
|
|
assert.equals('2026-03-15', fields.due)
|
|
end)
|
|
|
|
it('parses priority from notes', function()
|
|
local fields = gtasks._gtask_to_fields({
|
|
title = 'x',
|
|
status = 'needsAction',
|
|
notes = 'pri:1',
|
|
}, 'Work')
|
|
assert.equals(1, fields.priority)
|
|
end)
|
|
|
|
it('parses recur from notes', function()
|
|
local fields = gtasks._gtask_to_fields({
|
|
title = 'x',
|
|
status = 'needsAction',
|
|
notes = 'rec:weekly',
|
|
}, 'Work')
|
|
assert.equals('weekly', fields.recur)
|
|
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)
|