* feat(gtasks): add Google Tasks bidirectional sync
Problem: pending.nvim only supported one-way push to Google Calendar.
Users who use Google Tasks had no way to sync tasks bidirectionally.
Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE
auth, push/pull/sync actions, and field mapping between pending tasks
and Google Tasks (category↔tasklist, `priority`/`recur` via notes).
* refactor(cli): promote sync backends to top-level subcommands
Problem: `:Pending sync gtasks auth` required an extra `sync` keyword
that added no value and made the command unnecessarily verbose.
Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands
via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend
modules for available actions instead of hardcoding `{ 'auth', 'sync' }`.
* docs(gtasks): document Google Tasks backend and CLI changes
Problem: vimdoc had no coverage for the gtasks backend and still
referenced the old `:Pending sync <backend>` command form.
Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections
with per-action docs, update sync backend interface, and add gtasks
config example.
* ci: format
* refactor(sync): extract shared OAuth into `oauth.lua`
Problem: `gcal.lua` and `gtasks.lua` duplicated ~250 lines of identical
OAuth code (token management, PKCE flow, credential loading, curl
helpers, url encoding).
Solution: Extract a shared `OAuthClient` metatable in `oauth.lua` with
module-level utilities and instance methods. Both backends now delegate
all OAuth to `oauth.new()`. Skip `oauth` in `health.lua` backend
discovery by checking for a `name` field.
* feat(sync): ship bundled OAuth credentials
Problem: Users must manually create a Google Cloud project and place a
credentials JSON file before sync works — terrible onboarding.
Solution: Add `client_id`/`client_secret` fields to `GcalConfig` and
`GtasksConfig`. `oauth.lua` resolves credentials in three tiers: config
fields, credentials file, then bundled defaults (placeholders for now).
* docs(sync): document bundled credentials and config fields
* ci: format
230 lines
7.2 KiB
Lua
230 lines
7.2 KiB
Lua
require('spec.helpers')
|
|
|
|
local config = require('pending.config')
|
|
local oauth = require('pending.sync.oauth')
|
|
|
|
describe('oauth', function()
|
|
local tmpdir
|
|
|
|
before_each(function()
|
|
tmpdir = vim.fn.tempname()
|
|
vim.fn.mkdir(tmpdir, 'p')
|
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
|
config.reset()
|
|
end)
|
|
|
|
after_each(function()
|
|
vim.fn.delete(tmpdir, 'rf')
|
|
vim.g.pending = nil
|
|
config.reset()
|
|
end)
|
|
|
|
describe('url_encode', function()
|
|
it('leaves alphanumerics unchanged', function()
|
|
assert.equals('hello123', oauth.url_encode('hello123'))
|
|
end)
|
|
|
|
it('encodes spaces', function()
|
|
assert.equals('hello%20world', oauth.url_encode('hello world'))
|
|
end)
|
|
|
|
it('encodes special characters', function()
|
|
assert.equals('a%3Db%26c', oauth.url_encode('a=b&c'))
|
|
end)
|
|
|
|
it('preserves hyphens, dots, underscores, tildes', function()
|
|
assert.equals('a-b.c_d~e', oauth.url_encode('a-b.c_d~e'))
|
|
end)
|
|
end)
|
|
|
|
describe('load_json_file', function()
|
|
it('returns nil for missing file', function()
|
|
assert.is_nil(oauth.load_json_file(tmpdir .. '/nonexistent.json'))
|
|
end)
|
|
|
|
it('returns nil for empty file', function()
|
|
local path = tmpdir .. '/empty.json'
|
|
local f = io.open(path, 'w')
|
|
f:write('')
|
|
f:close()
|
|
assert.is_nil(oauth.load_json_file(path))
|
|
end)
|
|
|
|
it('returns nil for invalid JSON', function()
|
|
local path = tmpdir .. '/bad.json'
|
|
local f = io.open(path, 'w')
|
|
f:write('not json')
|
|
f:close()
|
|
assert.is_nil(oauth.load_json_file(path))
|
|
end)
|
|
|
|
it('parses valid JSON', function()
|
|
local path = tmpdir .. '/good.json'
|
|
local f = io.open(path, 'w')
|
|
f:write('{"key":"value"}')
|
|
f:close()
|
|
local data = oauth.load_json_file(path)
|
|
assert.equals('value', data.key)
|
|
end)
|
|
end)
|
|
|
|
describe('save_json_file', function()
|
|
it('creates parent directories', function()
|
|
local path = tmpdir .. '/sub/dir/file.json'
|
|
local ok = oauth.save_json_file(path, { test = true })
|
|
assert.is_true(ok)
|
|
local data = oauth.load_json_file(path)
|
|
assert.is_true(data.test)
|
|
end)
|
|
|
|
it('sets restrictive permissions', function()
|
|
local path = tmpdir .. '/secret.json'
|
|
oauth.save_json_file(path, { x = 1 })
|
|
local perms = vim.fn.getfperm(path)
|
|
assert.equals('rw-------', perms)
|
|
end)
|
|
end)
|
|
|
|
describe('resolve_credentials', function()
|
|
it('uses config fields when set', function()
|
|
config.reset()
|
|
vim.g.pending = {
|
|
data_path = tmpdir .. '/tasks.json',
|
|
sync = {
|
|
gtasks = {
|
|
client_id = 'config-id',
|
|
client_secret = 'config-secret',
|
|
},
|
|
},
|
|
}
|
|
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
|
|
local creds = c:resolve_credentials()
|
|
assert.equals('config-id', creds.client_id)
|
|
assert.equals('config-secret', creds.client_secret)
|
|
end)
|
|
|
|
it('uses credentials file when config fields absent', function()
|
|
local cred_path = tmpdir .. '/creds.json'
|
|
oauth.save_json_file(cred_path, {
|
|
client_id = 'file-id',
|
|
client_secret = 'file-secret',
|
|
})
|
|
config.reset()
|
|
vim.g.pending = {
|
|
data_path = tmpdir .. '/tasks.json',
|
|
sync = { gtasks = { credentials_path = cred_path } },
|
|
}
|
|
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
|
|
local creds = c:resolve_credentials()
|
|
assert.equals('file-id', creds.client_id)
|
|
assert.equals('file-secret', creds.client_secret)
|
|
end)
|
|
|
|
it('unwraps installed wrapper format', function()
|
|
local cred_path = tmpdir .. '/wrapped.json'
|
|
oauth.save_json_file(cred_path, {
|
|
installed = {
|
|
client_id = 'wrapped-id',
|
|
client_secret = 'wrapped-secret',
|
|
},
|
|
})
|
|
config.reset()
|
|
vim.g.pending = {
|
|
data_path = tmpdir .. '/tasks.json',
|
|
sync = { gcal = { credentials_path = cred_path } },
|
|
}
|
|
local c = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' })
|
|
local creds = c:resolve_credentials()
|
|
assert.equals('wrapped-id', creds.client_id)
|
|
assert.equals('wrapped-secret', creds.client_secret)
|
|
end)
|
|
|
|
it('falls back to bundled credentials', function()
|
|
config.reset()
|
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
|
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
|
|
local creds = c:resolve_credentials()
|
|
assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id)
|
|
assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret)
|
|
end)
|
|
|
|
it('prefers config fields over credentials file', function()
|
|
local cred_path = tmpdir .. '/creds2.json'
|
|
oauth.save_json_file(cred_path, {
|
|
client_id = 'file-id',
|
|
client_secret = 'file-secret',
|
|
})
|
|
config.reset()
|
|
vim.g.pending = {
|
|
data_path = tmpdir .. '/tasks.json',
|
|
sync = {
|
|
gtasks = {
|
|
credentials_path = cred_path,
|
|
client_id = 'config-id',
|
|
client_secret = 'config-secret',
|
|
},
|
|
},
|
|
}
|
|
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
|
|
local creds = c:resolve_credentials()
|
|
assert.equals('config-id', creds.client_id)
|
|
assert.equals('config-secret', creds.client_secret)
|
|
end)
|
|
end)
|
|
|
|
describe('token_path', function()
|
|
it('includes backend name', function()
|
|
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
|
|
assert.truthy(c:token_path():match('gtasks_tokens%.json$'))
|
|
end)
|
|
|
|
it('differs between backends', function()
|
|
local g = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' })
|
|
local t = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
|
|
assert.not_equals(g:token_path(), t:token_path())
|
|
end)
|
|
end)
|
|
|
|
describe('load_tokens / save_tokens', function()
|
|
it('round-trips tokens', function()
|
|
local c = oauth.new({ name = 'test', scope = 'x', port = 0, config_key = 'gtasks' })
|
|
local path = c:token_path()
|
|
local dir = vim.fn.fnamemodify(path, ':h')
|
|
vim.fn.mkdir(dir, 'p')
|
|
local tokens = {
|
|
access_token = 'at',
|
|
refresh_token = 'rt',
|
|
expires_in = 3600,
|
|
obtained_at = 1000,
|
|
}
|
|
c:save_tokens(tokens)
|
|
local loaded = c:load_tokens()
|
|
assert.equals('at', loaded.access_token)
|
|
assert.equals('rt', loaded.refresh_token)
|
|
vim.fn.delete(dir, 'rf')
|
|
end)
|
|
end)
|
|
|
|
describe('auth_headers', function()
|
|
it('includes bearer token', function()
|
|
local headers = oauth.auth_headers('mytoken')
|
|
assert.equals('Authorization: Bearer mytoken', headers[1])
|
|
assert.equals('Content-Type: application/json', headers[2])
|
|
end)
|
|
end)
|
|
|
|
describe('new', function()
|
|
it('creates client with correct fields', function()
|
|
local c = oauth.new({
|
|
name = 'test',
|
|
scope = 'https://example.com',
|
|
port = 12345,
|
|
config_key = 'test',
|
|
})
|
|
assert.equals('test', c.name)
|
|
assert.equals('https://example.com', c.scope)
|
|
assert.equals(12345, c.port)
|
|
assert.equals('test', c.config_key)
|
|
end)
|
|
end)
|
|
end)
|