pending.nvim/spec/oauth_spec.lua
Barrett Ruth 6910bdb1be Google Tasks sync + shared OAuth module (#60)
* 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
2026-03-05 01:21:18 -05:00

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)