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('ensure_credentials', function() it('returns true on valid credentials', function() util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then return { code = 0, stdout = '{"Account":"123"}', stderr = '' } end return { code = 0, stdout = '', stderr = '' } end assert.is_true(s3._ensure_credentials()) end) it('returns false on 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 assert.is_false(s3._ensure_credentials()) vim.notify = orig_notify assert.truthy(msg and msg:find('no AWS credentials')) end) it('retries SSO login on expired session', function() local calls = {} util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then return { code = 1, stdout = '', stderr = 'Error: SSO session expired' } end if vim.tbl_contains(args, 'sso') then table.insert(calls, 'sso-login') return { code = 0, stdout = '', stderr = '' } end return { code = 0, stdout = '', stderr = '' } end assert.is_true(s3._ensure_credentials()) assert.equals(1, #calls) assert.equals('sso-login', calls[1]) end) it('returns false when SSO login fails', function() util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then return { code = 1, stdout = '', stderr = 'SSO token expired' } end if vim.tbl_contains(args, 'sso') then return { code = 1, stdout = '', stderr = 'login failed' } end return { code = 0, stdout = '', stderr = '' } end assert.is_false(s3._ensure_credentials()) 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, 'get-caller-identity') then return { code = 0, stdout = '{"Account":"123"}', stderr = '' } end 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') util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then return { code = 0, stdout = '{"Account":"123"}', stderr = '' } 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.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)