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)