* feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing * feat(parse): add `parse_duration_to_days` for duration string conversion Problem: The archive command accepted only a bare integer for days, inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere. Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`, and bare integers. Returns nil on invalid input for caller error handling. * feat(archive): duration syntax and confirmation prompt Problem: `:Pending archive` accepted only a bare integer for days and silently deleted tasks with no confirmation, risking accidental data loss. Solution: Accept duration strings (`7d`, `3w`, `2m`) via `parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation prompt before removing tasks, and skip the prompt when zero tasks match. * feat: add `<C-a>` / `<C-x>` keymaps for priority increment/decrement Problem: Priority could only be cycled with `g!` (0→1→2→3→0), with no way to directly increment or decrement. Solution: Add `adjust_priority()` with clamping at 0 and `max_priority`, exposed as `increment_priority()` / `decrement_priority()` on `<C-a>` / `<C-x>`. Includes `<Plug>` mappings and vimdoc. * fix(s3): use parenthetical defaults in bucket creation prompts Problem: `util.input` with `default` pre-filled the input field, and the success message said "Add to your config" ambiguously. Solution: Show defaults in prompt text as `(default)` instead of pre-filling, and clarify the message to "Add to your pending.nvim config". * ci: format
558 lines
16 KiB
Lua
558 lines
16 KiB
Lua
require('spec.helpers')
|
|
|
|
local config = require('pending.config')
|
|
local util = require('pending.sync.util')
|
|
|
|
describe('s3', function()
|
|
local tmpdir
|
|
local pending
|
|
local s3
|
|
local orig_system
|
|
|
|
before_each(function()
|
|
tmpdir = vim.fn.tempname()
|
|
vim.fn.mkdir(tmpdir, 'p')
|
|
vim.g.pending = {
|
|
data_path = tmpdir .. '/tasks.json',
|
|
sync = { s3 = { bucket = 'test-bucket', key = 'test.json' } },
|
|
}
|
|
config.reset()
|
|
package.loaded['pending'] = nil
|
|
package.loaded['pending.sync.s3'] = nil
|
|
pending = require('pending')
|
|
s3 = require('pending.sync.s3')
|
|
orig_system = util.system
|
|
end)
|
|
|
|
after_each(function()
|
|
util.system = orig_system
|
|
vim.fn.delete(tmpdir, 'rf')
|
|
vim.g.pending = nil
|
|
config.reset()
|
|
package.loaded['pending'] = nil
|
|
package.loaded['pending.sync.s3'] = nil
|
|
end)
|
|
|
|
it('has correct name', function()
|
|
assert.equals('s3', s3.name)
|
|
end)
|
|
|
|
it('has auth function', function()
|
|
assert.equals('function', type(s3.auth))
|
|
end)
|
|
|
|
it('has auth_complete returning profile', function()
|
|
local completions = s3.auth_complete()
|
|
assert.is_true(vim.tbl_contains(completions, 'profile'))
|
|
end)
|
|
|
|
it('has push, pull, sync functions', function()
|
|
assert.equals('function', type(s3.push))
|
|
assert.equals('function', type(s3.pull))
|
|
assert.equals('function', type(s3.sync))
|
|
end)
|
|
|
|
it('has health function', function()
|
|
assert.equals('function', type(s3.health))
|
|
end)
|
|
|
|
describe('ensure_sync_id', function()
|
|
it('assigns a UUID-like sync id', function()
|
|
local task = { _extra = nil, modified = '2026-01-01T00:00:00Z' }
|
|
local id = s3._ensure_sync_id(task)
|
|
assert.is_not_nil(id)
|
|
assert.truthy(
|
|
id:match('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$')
|
|
)
|
|
assert.equals(id, task._extra['_s3_sync_id'])
|
|
end)
|
|
|
|
it('returns existing sync id without regenerating', function()
|
|
local task = {
|
|
_extra = { _s3_sync_id = 'existing-id' },
|
|
modified = '2026-01-01T00:00:00Z',
|
|
}
|
|
local id = s3._ensure_sync_id(task)
|
|
assert.equals('existing-id', id)
|
|
end)
|
|
end)
|
|
|
|
describe('auth', function()
|
|
it('reports success on valid credentials', function()
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
local msg
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(m)
|
|
msg = m
|
|
end
|
|
s3.auth()
|
|
vim.notify = orig_notify
|
|
assert.truthy(msg and msg:find('authenticated'))
|
|
end)
|
|
|
|
it('skips bucket creation when bucket is configured', function()
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
local orig_input = util.input
|
|
local input_called = false
|
|
util.input = function()
|
|
input_called = true
|
|
return nil
|
|
end
|
|
s3.auth()
|
|
util.input = orig_input
|
|
assert.is_false(input_called)
|
|
end)
|
|
|
|
it('detects SSO expiry', function()
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return { code = 1, stdout = '', stderr = 'Error: SSO session expired' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
local msg
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(m)
|
|
msg = m
|
|
end
|
|
s3.auth()
|
|
vim.notify = orig_notify
|
|
assert.truthy(msg and msg:find('SSO'))
|
|
end)
|
|
|
|
it('detects missing credentials', function()
|
|
util.system = function()
|
|
return { code = 1, stdout = '', stderr = 'Unable to locate credentials' }
|
|
end
|
|
local msg
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(m, level)
|
|
if level == vim.log.levels.ERROR then
|
|
msg = m
|
|
end
|
|
end
|
|
s3.auth()
|
|
vim.notify = orig_notify
|
|
assert.truthy(msg and msg:find('no AWS credentials'))
|
|
end)
|
|
end)
|
|
|
|
describe('auth bucket creation', function()
|
|
local orig_input
|
|
|
|
before_each(function()
|
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } }
|
|
config.reset()
|
|
package.loaded['pending'] = nil
|
|
package.loaded['pending.sync.s3'] = nil
|
|
pending = require('pending')
|
|
s3 = require('pending.sync.s3')
|
|
orig_input = util.input
|
|
end)
|
|
|
|
after_each(function()
|
|
util.input = orig_input
|
|
end)
|
|
|
|
it('prompts for bucket when none configured', function()
|
|
local input_calls = {}
|
|
util.input = function(opts)
|
|
table.insert(input_calls, opts)
|
|
if opts.prompt:find('bucket') then
|
|
return 'my-bucket'
|
|
end
|
|
return ''
|
|
end
|
|
local create_args
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
if vim.tbl_contains(args, 'configure') then
|
|
return { code = 0, stdout = 'us-west-2\n', stderr = '' }
|
|
end
|
|
if vim.tbl_contains(args, 'create-bucket') then
|
|
create_args = args
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
local msg
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(m)
|
|
msg = m
|
|
end
|
|
s3.auth()
|
|
vim.notify = orig_notify
|
|
assert.equals(2, #input_calls)
|
|
assert.is_not_nil(create_args)
|
|
assert.truthy(vim.tbl_contains(create_args, 'my-bucket'))
|
|
assert.truthy(msg and msg:find('bucket created'))
|
|
end)
|
|
|
|
it('cancels when user provides nil bucket name', function()
|
|
util.input = function(opts)
|
|
if opts.prompt:find('bucket') then
|
|
return nil
|
|
end
|
|
return ''
|
|
end
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
local msg
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(m)
|
|
msg = m
|
|
end
|
|
s3.auth()
|
|
vim.notify = orig_notify
|
|
assert.truthy(msg and msg:find('cancelled'))
|
|
end)
|
|
|
|
it('omits LocationConstraint for us-east-1', function()
|
|
util.input = function(opts)
|
|
if opts.prompt:find('bucket') then
|
|
return 'my-bucket'
|
|
end
|
|
if opts.prompt:find('region') then
|
|
return 'us-east-1'
|
|
end
|
|
return ''
|
|
end
|
|
local create_args
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
if vim.tbl_contains(args, 'configure') then
|
|
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
|
|
end
|
|
if vim.tbl_contains(args, 'create-bucket') then
|
|
create_args = args
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
s3.auth()
|
|
assert.is_not_nil(create_args)
|
|
local joined = table.concat(create_args, ' ')
|
|
assert.falsy(joined:find('LocationConstraint'))
|
|
end)
|
|
|
|
it('includes LocationConstraint for non-us-east-1 regions', function()
|
|
util.input = function(opts)
|
|
if opts.prompt:find('bucket') then
|
|
return 'my-bucket'
|
|
end
|
|
if opts.prompt:find('region') then
|
|
return 'eu-west-1'
|
|
end
|
|
return ''
|
|
end
|
|
local create_args
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
if vim.tbl_contains(args, 'configure') then
|
|
return { code = 0, stdout = 'eu-west-1\n', stderr = '' }
|
|
end
|
|
if vim.tbl_contains(args, 'create-bucket') then
|
|
create_args = args
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
s3.auth()
|
|
assert.is_not_nil(create_args)
|
|
assert.truthy(vim.tbl_contains(create_args, 'LocationConstraint=eu-west-1'))
|
|
end)
|
|
|
|
it('reports error on bucket creation failure', function()
|
|
util.input = function(opts)
|
|
if opts.prompt:find('bucket') then
|
|
return 'bad-bucket'
|
|
end
|
|
return ''
|
|
end
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
if vim.tbl_contains(args, 'configure') then
|
|
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
|
|
end
|
|
if vim.tbl_contains(args, 'create-bucket') then
|
|
return { code = 1, stdout = '', stderr = 'BucketAlreadyExists' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
local msg
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(m, level)
|
|
if level == vim.log.levels.ERROR then
|
|
msg = m
|
|
end
|
|
end
|
|
s3.auth()
|
|
vim.notify = orig_notify
|
|
assert.truthy(msg and msg:find('bucket creation failed'))
|
|
end)
|
|
|
|
it('defaults region to us-east-1 when aws configure returns nothing', function()
|
|
util.input = function(opts)
|
|
if opts.prompt:find('bucket') then
|
|
return 'my-bucket'
|
|
end
|
|
return ''
|
|
end
|
|
local create_args
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
|
return {
|
|
code = 0,
|
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
|
stderr = '',
|
|
}
|
|
end
|
|
if vim.tbl_contains(args, 'configure') then
|
|
return { code = 1, stdout = '', stderr = '' }
|
|
end
|
|
if vim.tbl_contains(args, 'create-bucket') then
|
|
create_args = args
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
s3.auth()
|
|
assert.is_not_nil(create_args)
|
|
assert.truthy(vim.tbl_contains(create_args, 'us-east-1'))
|
|
local joined = table.concat(create_args, ' ')
|
|
assert.falsy(joined:find('LocationConstraint'))
|
|
end)
|
|
end)
|
|
|
|
describe('push', function()
|
|
it('uploads store to S3', function()
|
|
local s = pending.store()
|
|
s:load()
|
|
s:add({ description = 'Test task', status = 'pending', category = 'Work', priority = 0 })
|
|
s:save()
|
|
|
|
local captured_args
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 's3') then
|
|
captured_args = args
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
|
|
s3.push()
|
|
|
|
assert.is_not_nil(captured_args)
|
|
local joined = table.concat(captured_args, ' ')
|
|
assert.truthy(joined:find('s3://test%-bucket/test%.json'))
|
|
end)
|
|
|
|
it('errors when bucket is not configured', function()
|
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } }
|
|
config.reset()
|
|
package.loaded['pending'] = nil
|
|
package.loaded['pending.sync.s3'] = nil
|
|
pending = require('pending')
|
|
s3 = require('pending.sync.s3')
|
|
|
|
local msg
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(m, level)
|
|
if level == vim.log.levels.ERROR then
|
|
msg = m
|
|
end
|
|
end
|
|
s3.push()
|
|
vim.notify = orig_notify
|
|
assert.truthy(msg and msg:find('bucket is required'))
|
|
end)
|
|
end)
|
|
|
|
describe('pull merge', function()
|
|
it('merges remote tasks by sync_id', function()
|
|
local store_mod = require('pending.store')
|
|
local s = pending.store()
|
|
s:load()
|
|
local local_task = s:add({
|
|
description = 'Local task',
|
|
status = 'pending',
|
|
category = 'Work',
|
|
priority = 0,
|
|
})
|
|
local_task._extra = { _s3_sync_id = 'sync-1' }
|
|
local_task.modified = '2026-03-01T00:00:00Z'
|
|
s:save()
|
|
|
|
local remote_path = tmpdir .. '/remote.json'
|
|
local remote_store = store_mod.new(remote_path)
|
|
remote_store:load()
|
|
local remote_task = remote_store:add({
|
|
description = 'Updated remotely',
|
|
status = 'pending',
|
|
category = 'Work',
|
|
priority = 1,
|
|
})
|
|
remote_task._extra = { _s3_sync_id = 'sync-1' }
|
|
remote_task.modified = '2026-03-05T00:00:00Z'
|
|
|
|
local new_remote = remote_store:add({
|
|
description = 'New remote task',
|
|
status = 'pending',
|
|
category = 'Personal',
|
|
priority = 0,
|
|
})
|
|
new_remote._extra = { _s3_sync_id = 'sync-2' }
|
|
new_remote.modified = '2026-03-04T00:00:00Z'
|
|
remote_store:save()
|
|
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then
|
|
for i, arg in ipairs(args) do
|
|
if arg:match('^s3://') then
|
|
local dest = args[i + 1]
|
|
if dest and not dest:match('^s3://') then
|
|
local src = io.open(remote_path, 'r')
|
|
local content = src:read('*a')
|
|
src:close()
|
|
local f = io.open(dest, 'w')
|
|
f:write(content)
|
|
f:close()
|
|
end
|
|
break
|
|
end
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
|
|
s3.pull()
|
|
|
|
s:load()
|
|
local tasks = s:tasks()
|
|
assert.equals(2, #tasks)
|
|
|
|
local found_updated = false
|
|
local found_new = false
|
|
for _, t in ipairs(tasks) do
|
|
if t._extra and t._extra['_s3_sync_id'] == 'sync-1' then
|
|
assert.equals('Updated remotely', t.description)
|
|
assert.equals(1, t.priority)
|
|
found_updated = true
|
|
end
|
|
if t._extra and t._extra['_s3_sync_id'] == 'sync-2' then
|
|
assert.equals('New remote task', t.description)
|
|
found_new = true
|
|
end
|
|
end
|
|
assert.is_true(found_updated)
|
|
assert.is_true(found_new)
|
|
end)
|
|
|
|
it('keeps local version when local is newer', function()
|
|
local s = pending.store()
|
|
s:load()
|
|
local local_task = s:add({
|
|
description = 'Local version',
|
|
status = 'pending',
|
|
category = 'Work',
|
|
priority = 0,
|
|
})
|
|
local_task._extra = { _s3_sync_id = 'sync-3' }
|
|
local_task.modified = '2026-03-10T00:00:00Z'
|
|
s:save()
|
|
|
|
local store_mod = require('pending.store')
|
|
local remote_path = tmpdir .. '/remote2.json'
|
|
local remote_store = store_mod.new(remote_path)
|
|
remote_store:load()
|
|
local remote_task = remote_store:add({
|
|
description = 'Older remote',
|
|
status = 'pending',
|
|
category = 'Work',
|
|
priority = 0,
|
|
})
|
|
remote_task._extra = { _s3_sync_id = 'sync-3' }
|
|
remote_task.modified = '2026-03-05T00:00:00Z'
|
|
remote_store:save()
|
|
|
|
util.system = function(args)
|
|
if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then
|
|
for i, arg in ipairs(args) do
|
|
if arg:match('^s3://') then
|
|
local dest = args[i + 1]
|
|
if dest and not dest:match('^s3://') then
|
|
local src = io.open(remote_path, 'r')
|
|
local content = src:read('*a')
|
|
src:close()
|
|
local f = io.open(dest, 'w')
|
|
f:write(content)
|
|
f:close()
|
|
end
|
|
break
|
|
end
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
return { code = 0, stdout = '', stderr = '' }
|
|
end
|
|
|
|
s3.pull()
|
|
|
|
s:load()
|
|
local tasks = s:tasks()
|
|
assert.equals(1, #tasks)
|
|
assert.equals('Local version', tasks[1].description)
|
|
end)
|
|
end)
|
|
end)
|