aboutsummaryrefslogtreecommitdiff
path: root/tests/test-pearl-sync.el
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test-pearl-sync.el')
-rw-r--r--tests/test-pearl-sync.el206
1 files changed, 206 insertions, 0 deletions
diff --git a/tests/test-pearl-sync.el b/tests/test-pearl-sync.el
new file mode 100644
index 0000000..6127914
--- /dev/null
+++ b/tests/test-pearl-sync.el
@@ -0,0 +1,206 @@
+;;; test-pearl-sync.el --- Tests for description sync-back -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the explicit description sync-back: the pure conflict gate
+;; (`pearl--sync-decision'), the org body extractor
+;; (`pearl--issue-body-at-point'), the two network helpers (fetch the
+;; remote description, push an updated description -- both stubbed at the HTTP
+;; boundary), and the orchestrating command `pearl-sync-current-issue'
+;; across its no-op / clean-push / conflict branches.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT at point-min."
+ (declare (indent 1))
+ `(let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; --sync-decision (pure conflict gate)
+
+(ert-deftest test-pearl-sync-decision-noop-no-local-edit ()
+ "Local matches the stored baseline: no local edit, no push."
+ (let ((md "the description"))
+ (should (eq :noop (pearl--sync-decision
+ md (secure-hash 'sha256 md) "remote moved on")))))
+
+(ert-deftest test-pearl-sync-decision-push-remote-unchanged ()
+ "Local edited and remote still equals the baseline: clean push."
+ (should (eq :push (pearl--sync-decision
+ "edited locally" (secure-hash 'sha256 "baseline") "baseline"))))
+
+(ert-deftest test-pearl-sync-decision-conflict-both-changed ()
+ "Local and remote both moved away from the baseline, differently: conflict."
+ (should (eq :conflict (pearl--sync-decision
+ "edited local" (secure-hash 'sha256 "baseline") "edited remote"))))
+
+(ert-deftest test-pearl-sync-decision-noop-converged ()
+ "Local and remote ended up identical though both differ from baseline: no push."
+ (should (eq :noop (pearl--sync-decision
+ "same new text" (secure-hash 'sha256 "baseline") "same new text"))))
+
+;;; --issue-body-at-point (org body extractor)
+
+(ert-deftest test-pearl-issue-body-after-drawer ()
+ "The body is the text after the drawer, trimmed."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nThe body line.\nSecond line.\n"
+ (re-search-forward "Title")
+ (should (string= "The body line.\nSecond line."
+ (pearl--issue-body-at-point)))))
+
+(ert-deftest test-pearl-issue-body-empty ()
+ "An entry with no body yields the empty string."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (should (string= "" (pearl--issue-body-at-point)))))
+
+(ert-deftest test-pearl-issue-body-stops-before-comments ()
+ "The description body stops before a child Comments subtree."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nDesc body.\n**** Comments\n***** bob -- ts\nhi\n"
+ (should (string= "Desc body." (pearl--issue-body-at-point)))))
+
+(ert-deftest test-pearl-issue-body-from-inside-body ()
+ "Extraction works with point already inside the body."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nDesc body.\nmore.\n"
+ (goto-char (point-max))
+ (should (string= "Desc body.\nmore." (pearl--issue-body-at-point)))))
+
+;;; network helpers (stubbed at the HTTP boundary)
+
+(ert-deftest test-pearl-fetch-issue-description-parses-payload ()
+ "The fetch helper returns the remote description and timestamp as a plist."
+ (testutil-linear-with-response
+ '((data (issue (description . "remote markdown")
+ (updatedAt . "2026-05-23T00:00:00.000Z"))))
+ (let (result)
+ (pearl--fetch-issue-description-async
+ "id-1" (lambda (r) (setq result r)))
+ (should (string= "remote markdown" (plist-get result :description)))
+ (should (string= "2026-05-23T00:00:00.000Z" (plist-get result :updated-at))))))
+
+(ert-deftest test-pearl-update-issue-description-success ()
+ "A successful issueUpdate reports success and carries the new timestamp."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . t)
+ (issue (id . "id-1")
+ (updatedAt . "2026-05-23T01:00:00.000Z")))))
+ (let (result)
+ (pearl--update-issue-description-async
+ "id-1" "new body" (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success)))
+ (should (string= "2026-05-23T01:00:00.000Z" (plist-get result :updated-at))))))
+
+(ert-deftest test-pearl-update-issue-description-soft-fail ()
+ "A non-success issueUpdate reports failure rather than erroring."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . :json-false) (issue . nil))))
+ (let ((called nil) result)
+ (pearl--update-issue-description-async
+ "id-1" "new body" (lambda (r) (setq called t result r)))
+ (should called)
+ (should-not (plist-get result :success)))))
+
+;;; pearl-sync-current-issue (orchestration branches)
+
+(defmacro test-pearl--with-sync-stubs (fetch-remote update-spy &rest body)
+ "Run BODY with the two network helpers stubbed.
+FETCH-REMOTE is the plist the fetch helper hands its callback. UPDATE-SPY is
+a symbol bound to a list that captures the markdown passed to the update
+helper (nil when never called); the update helper reports success."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'pearl--fetch-issue-description-async)
+ (lambda (_id cb) (funcall cb ,fetch-remote)))
+ ((symbol-function 'pearl--update-issue-description-async)
+ (lambda (_id md cb)
+ (push md ,update-spy)
+ (funcall cb '(:success t :updated-at "2026-05-23T02:00:00.000Z")))))
+ ,@body))
+
+(ert-deftest test-pearl-sync-current-issue-noop-skips-network ()
+ "No local edit: neither the fetch nor the update helper is called."
+ (let ((md "Hello **world** and `code`.")
+ (fetched nil) (updates nil))
+ (test-pearl--in-org
+ (format "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: %s\n:END:\n%s\n"
+ (secure-hash 'sha256 "Hello **world** and `code`.")
+ (pearl--md-to-org "Hello **world** and `code`."))
+ (ignore md)
+ (cl-letf (((symbol-function 'pearl--fetch-issue-description-async)
+ (lambda (&rest _) (setq fetched t)))
+ ((symbol-function 'pearl--update-issue-description-async)
+ (lambda (&rest _) (push 'called updates))))
+ (pearl-sync-current-issue)
+ (should-not fetched)
+ (should-not updates)))))
+
+(ert-deftest test-pearl-sync-current-issue-push-updates-provenance ()
+ "Local edit + remote unchanged: push the rendered markdown, update the hash."
+ (let ((updates nil))
+ (test-pearl--in-org
+ (format "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: %s\n:LINEAR-DESC-UPDATED-AT: old\n:END:\nEdited body now.\n"
+ (secure-hash 'sha256 "baseline remote"))
+ (test-pearl--with-sync-stubs '(:description "baseline remote" :updated-at "t0") updates
+ (pearl-sync-current-issue)
+ ;; the pushed markdown is the org body rendered back to md
+ (should (equal (list (pearl--org-to-md "Edited body now."))
+ updates))
+ ;; provenance advanced to the pushed content + the push timestamp
+ (should (string= (secure-hash 'sha256 (pearl--org-to-md "Edited body now."))
+ (org-entry-get nil "LINEAR-DESC-SHA256")))
+ (should (string= "2026-05-23T02:00:00.000Z"
+ (org-entry-get nil "LINEAR-DESC-UPDATED-AT")))))))
+
+(ert-deftest test-pearl-sync-current-issue-conflict-refuses ()
+ "Local edit + remote also changed: refuse, do not push, keep provenance."
+ (let ((updates nil)
+ (stored (secure-hash 'sha256 "baseline remote")))
+ (test-pearl--in-org
+ (format "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: %s\n:END:\nEdited body now.\n"
+ stored)
+ (test-pearl--with-sync-stubs '(:description "remote changed too" :updated-at "t1") updates
+ ;; On conflict the command now prompts; cancel preserves the old
+ ;; refuse-and-keep-provenance behavior.
+ (cl-letf (((symbol-function 'pearl--read-conflict-resolution)
+ (lambda (_label) 'cancel)))
+ (pearl-sync-current-issue)
+ (should-not updates)
+ ;; provenance untouched when the conflict is cancelled
+ (should (string= stored (org-entry-get nil "LINEAR-DESC-SHA256"))))))))
+
+(ert-deftest test-pearl-sync-current-issue-not-on-issue-errors ()
+ "Running the command outside a Linear issue heading signals a user error."
+ (test-pearl--in-org
+ "* Just a plain heading\nno linear id here\n"
+ (should-error (pearl-sync-current-issue) :type 'user-error)))
+
+(provide 'test-pearl-sync)
+;;; test-pearl-sync.el ends here