aboutsummaryrefslogtreecommitdiff
path: root/tests/test-pearl-saved-query-sync.el
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test-pearl-saved-query-sync.el')
-rw-r--r--tests/test-pearl-saved-query-sync.el379
1 files changed, 379 insertions, 0 deletions
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
new file mode 100644
index 0000000..7a40566
--- /dev/null
+++ b/tests/test-pearl-saved-query-sync.el
@@ -0,0 +1,379 @@
+;;; test-pearl-saved-query-sync.el --- Tests for syncing saved queries to Linear views -*- 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 `pearl-sync-saved-query-to-linear' and its helpers
+;; (`pearl--sync-scope-candidates', `pearl--find-view-by-name-in-scope',
+;; `pearl--customview-create-async', `pearl--customview-update-async',
+;; `pearl--save-query-mark-synced', `pearl--sync-record-or-orphan-error').
+;; See docs/saved-query-sync-spec.org.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"
+ (file-name-directory
+ (or load-file-name buffer-file-name))))
+(require 'testutil-request (expand-file-name "testutil-request.el"
+ (file-name-directory
+ (or load-file-name buffer-file-name))))
+(require 'cl-lib)
+
+;;; pearl--sync-scope-candidates
+
+(ert-deftest test-pearl-sync-scope-candidates-default-with-filter-team ()
+ "When the filter has `:team X', the default candidate is the team-shared row."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))
+ ((id . "team-mkt-id") (key . "MKT") (name . "Marketing"))))
+ (cands (pearl--sync-scope-candidates teams "ENG")))
+ (should (string= "[ Team: Engineering, visible to the team ]" (caar cands)))
+ (let ((plist (cdar cands)))
+ (should (equal "team-eng-id" (plist-get plist :team-id)))
+ (should (equal "Engineering" (plist-get plist :team-name)))
+ (should (eq t (plist-get plist :shared))))))
+
+(ert-deftest test-pearl-sync-scope-candidates-default-personal-no-filter-team ()
+ "Without `:team' in the filter, the default candidate is Personal."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (cands (pearl--sync-scope-candidates teams nil)))
+ (should (string= "[ Personal, only I see it ]" (caar cands)))
+ (let ((plist (cdar cands)))
+ (should (null (plist-get plist :team-id)))
+ (should (equal "Personal" (plist-get plist :team-name)))
+ (should (null (plist-get plist :shared))))))
+
+(ert-deftest test-pearl-sync-scope-candidates-unknown-filter-team-falls-back-to-personal ()
+ "An unknown `:team' key in the filter falls back to Personal as the default."
+ (let* ((teams '(((id . "id-a") (key . "A") (name . "Apple"))))
+ (cands (pearl--sync-scope-candidates teams "GONE")))
+ (should (string= "[ Personal, only I see it ]" (caar cands)))))
+
+(ert-deftest test-pearl-sync-scope-candidates-no-duplicate-default-row ()
+ "The default candidate appears only once even when it matches a real row."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (cands (pearl--sync-scope-candidates teams "ENG"))
+ (count (cl-count "[ Team: Engineering, visible to the team ]" cands
+ :key #'car :test #'string=)))
+ (should (= 1 count))))
+
+(ert-deftest test-pearl-sync-scope-candidates-includes-personal-and-both-team-rows ()
+ "The list carries one Personal row plus two rows per team."
+ (let* ((teams '(((id . "id-a") (key . "A") (name . "Apple"))
+ ((id . "id-b") (key . "B") (name . "Banana"))))
+ (cands (pearl--sync-scope-candidates teams nil)))
+ ;; 1 personal + 2 teams x 2 rows = 5
+ (should (= 5 (length cands)))
+ (dolist (display '("[ Personal, only I see it ]"
+ "[ Team: Apple, visible to the team ]"
+ "[ Team: Apple, only I see it ]"
+ "[ Team: Banana, visible to the team ]"
+ "[ Team: Banana, only I see it ]"))
+ (should (cl-find display cands :key #'car :test #'string=)))))
+
+(ert-deftest test-pearl-sync-scope-candidates-no-meaningless-personal-shared ()
+ "No `[ Personal, visible to the team ]' row exists -- it would be meaningless."
+ (let* ((teams '(((id . "id-a") (key . "A") (name . "Apple"))))
+ (cands (pearl--sync-scope-candidates teams nil)))
+ (should-not (cl-find "[ Personal, visible to the team ]" cands
+ :key #'car :test #'string=))))
+
+(ert-deftest test-pearl-sync-scope-candidates-teams-sorted-alphabetically ()
+ "Non-default team rows appear in alphabetical order by team name."
+ (let* ((teams '(((id . "id-z") (key . "Z") (name . "Zebra"))
+ ((id . "id-a") (key . "A") (name . "Apple"))
+ ((id . "id-m") (key . "M") (name . "Mango"))))
+ (cands (pearl--sync-scope-candidates teams nil))
+ (displays (mapcar #'car cands))
+ (apple-pos (cl-position "[ Team: Apple, visible to the team ]"
+ displays :test #'string=))
+ (mango-pos (cl-position "[ Team: Mango, visible to the team ]"
+ displays :test #'string=))
+ (zebra-pos (cl-position "[ Team: Zebra, visible to the team ]"
+ displays :test #'string=)))
+ (should (and apple-pos mango-pos zebra-pos
+ (< apple-pos mango-pos)
+ (< mango-pos zebra-pos)))))
+
+;;; pearl--find-view-by-name-in-scope
+
+(ert-deftest test-pearl-find-view-by-name-in-scope-matches-team-shared ()
+ "Finds a view by name + matching team id + shared flag."
+ (let ((views '(((id . "v1") (name . "A") (shared . t)
+ (team (id . "t1")))
+ ((id . "v2") (name . "A") (shared . :json-false)
+ (team (id . "t1")))
+ ((id . "v3") (name . "B") (shared . t)
+ (team . nil)))))
+ (should (equal "v1" (cdr (assoc 'id
+ (pearl--find-view-by-name-in-scope
+ views "A" "t1" t)))))
+ (should (equal "v2" (cdr (assoc 'id
+ (pearl--find-view-by-name-in-scope
+ views "A" "t1" nil)))))))
+
+(ert-deftest test-pearl-find-view-by-name-in-scope-matches-personal ()
+ "Finds a personal (no team) view when scope matches."
+ (let ((views '(((id . "v1") (name . "A") (shared . :json-false)
+ (team . nil)))))
+ (should (equal "v1" (cdr (assoc 'id
+ (pearl--find-view-by-name-in-scope
+ views "A" nil nil)))))))
+
+(ert-deftest test-pearl-find-view-by-name-in-scope-no-match-returns-nil ()
+ "Returns nil when nothing matches name or scope."
+ (let ((views '(((id . "v1") (name . "A") (shared . t)
+ (team (id . "t1"))))))
+ (should (null (pearl--find-view-by-name-in-scope views "A" "t2" t)))
+ (should (null (pearl--find-view-by-name-in-scope views "Other" "t1" t)))
+ (should (null (pearl--find-view-by-name-in-scope views "A" nil nil)))))
+
+;;; pearl--customview-create-async
+
+(ert-deftest test-pearl-customview-create-async-success ()
+ "A successful `customViewCreate' invokes the callback with `:success' t and the view."
+ (testutil-linear-with-response
+ '((data
+ (customViewCreate
+ (success . t)
+ (customView (id . "view-uuid") (name . "X")
+ (shared . :json-false) (team)))))
+ (let (result)
+ (pearl--customview-create-async
+ "X" nil nil
+ '(("state" ("type" ("nin" . ["completed"]))))
+ (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success)))
+ (should (equal "view-uuid"
+ (cdr (assoc 'id (plist-get result :view))))))))
+
+(ert-deftest test-pearl-customview-create-async-failure ()
+ "A non-success `customViewCreate' invokes the callback with `:success' nil."
+ (testutil-linear-with-response
+ '((data (customViewCreate (success . :json-false) (customView))))
+ (let (result)
+ (pearl--customview-create-async
+ "X" nil nil '() (lambda (r) (setq result r)))
+ (should-not (plist-get result :success)))))
+
+;;; pearl--customview-update-async
+
+(ert-deftest test-pearl-customview-update-async-success ()
+ "A successful `customViewUpdate' invokes the callback with `:success' t."
+ (testutil-linear-with-response
+ '((data
+ (customViewUpdate
+ (success . t)
+ (customView (id . "view-uuid") (name . "X")
+ (shared . t)
+ (team (id . "team-id"))))))
+ (let (result)
+ (pearl--customview-update-async
+ "view-uuid" '() (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success))))))
+
+(ert-deftest test-pearl-customview-update-async-failure ()
+ "A non-success `customViewUpdate' invokes the callback with `:success' nil."
+ (testutil-linear-with-response
+ '((data (customViewUpdate (success . :json-false) (customView))))
+ (let (result)
+ (pearl--customview-update-async
+ "view-uuid" '() (lambda (r) (setq result r)))
+ (should-not (plist-get result :success)))))
+
+;;; pearl--save-query-mark-synced
+
+(ert-deftest test-pearl-save-query-mark-synced-adds-the-four-keys ()
+ "After mark-synced, the entry carries `:linear-view-id', `-team-id', `-shared', `-synced-at'."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t))))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced "Q" "team-uuid" t "view-uuid"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "view-uuid" (plist-get spec :linear-view-id)))
+ (should (equal "team-uuid" (plist-get spec :linear-view-team-id)))
+ (should (eq t (plist-get spec :linear-view-shared)))
+ (should (stringp (plist-get spec :linear-view-synced-at)))
+ ;; Original filter survives
+ (should (equal '(:open t) (plist-get spec :filter))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-preserves-sort-and-order ()
+ "Mark-synced doesn't drop `:sort' or `:order' from the entry."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t) :sort updated :order desc)))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced "Q" nil nil "view-uuid"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (eq 'updated (plist-get spec :sort)))
+ (should (eq 'desc (plist-get spec :order))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-personal-nil-team ()
+ "Personal scope persists `:linear-view-team-id' as nil and `:linear-view-shared' nil."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t))))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced "Q" nil nil "view-uuid"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (null (plist-get spec :linear-view-team-id)))
+ (should (null (plist-get spec :linear-view-shared))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-unknown-name-signals-user-error ()
+ "Mark-synced on a name that doesn't exist signals a `user-error'."
+ (let ((pearl-saved-queries '()))
+ (should-error
+ (pearl--save-query-mark-synced "Q" nil nil "view-uuid")
+ :type 'user-error)))
+
+(ert-deftest test-pearl-save-query-mark-synced-overwrites-prior-sync-metadata ()
+ "Re-syncing an already-synced entry overwrites the four `:linear-view-*' keys."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "OLD-id"
+ :linear-view-team-id "OLD-team"
+ :linear-view-shared :json-false
+ :linear-view-synced-at "1970-01-01T00:00:00Z")))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced "Q" "NEW-team" t "NEW-id"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "NEW-id" (plist-get spec :linear-view-id)))
+ (should (equal "NEW-team" (plist-get spec :linear-view-team-id)))
+ (should (eq t (plist-get spec :linear-view-shared)))
+ (should-not (equal "1970-01-01T00:00:00Z"
+ (plist-get spec :linear-view-synced-at))))))
+
+;;; pearl--sync-record-or-orphan-error
+
+(ert-deftest test-pearl-sync-record-orphan-error-names-view-id-on-persist-failure ()
+ "When persist fails, the message names the orphan view id explicitly."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) (error "disk full")))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (let ((result (pearl--sync-record-or-orphan-error
+ "Q" nil nil "ORPHAN-UUID-XYZ" "Synced")))
+ (should (null result))
+ (should (string-match-p "ORPHAN-UUID-XYZ" msg))))))
+
+(ert-deftest test-pearl-sync-record-orphan-error-success-returns-t-and-includes-view-id ()
+ "When persist succeeds, the helper returns t and the message names the view id."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) t))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (let ((result (pearl--sync-record-or-orphan-error
+ "Q" nil nil "view-uuid" "Synced")))
+ (should (eq t result))
+ (should (string-match-p "view-uuid" msg))
+ (should (string-match-p "Q" msg))
+ (should (string-match-p "Synced" msg))))))
+
+(ert-deftest test-pearl-sync-record-orphan-error-includes-scope-label-when-given ()
+ "An optional SCOPE-LABEL is appended in brackets to the success message."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) t))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (pearl--sync-record-or-orphan-error
+ "Q" nil nil "view-uuid" "Synced" "[ Team: Eng, visible to the team ]")
+ (should (string-match-p "Team: Eng" msg)))))
+
+(ert-deftest test-pearl-sync-record-orphan-error-update-verb-rewrites-failure-text ()
+ "The failure message uses the lowercase verb (\"updated\") on an update path."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) (error "disk full")))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (pearl--sync-record-or-orphan-error
+ "Q" nil nil "view-uuid" "Updated")
+ (should (string-match-p "updated on Linear" msg)))))
+
+;;; pearl--sync-saved-query-await — timeout detection
+
+(ert-deftest test-pearl-sync-saved-query-await-marks-timeout-when-callback-misses ()
+ "A callback that never fires returns `(:success nil :timeout t)' rather than nil."
+ (let ((pearl-request-timeout 0.05)) ; ~50ms is plenty to fall through the wait loop
+ (cl-letf (((symbol-function 'noop-async)
+ (lambda (&rest _ignored) nil))) ; never invokes callback
+ (let ((result (pearl--sync-saved-query-await #'noop-async)))
+ (should (eq t (plist-get result :timeout)))
+ (should-not (plist-get result :success))))))
+
+(ert-deftest test-pearl-sync-saved-query-await-returns-callback-result-on-completion ()
+ "When the callback fires, the helper returns that result verbatim (no timeout flag)."
+ (cl-letf (((symbol-function 'sync-fire-async)
+ (lambda (cb) (funcall cb (list :success t :view '((id . "X")))))))
+ (let ((result (pearl--sync-saved-query-await #'sync-fire-async)))
+ (should (eq t (plist-get result :success)))
+ (should-not (plist-get result :timeout)))))
+
+;;; Re-sync normalizes a stored `:json-false' shared flag — guards the silent flip
+
+(ert-deftest test-pearl-sync-resync-normalizes-json-false-shared-to-personal ()
+ "Re-sync of an entry with `:linear-view-shared :json-false' must NOT flip it shared.
+`:json-false' is truthy in Elisp, so a stored `:json-false' would slip past
+`(if shared t :json-false)' in the encoder and silently make the view team-shared."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "view-id"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false
+ :linear-view-synced-at "2026-01-01T00:00:00Z"))))
+ (captured-input nil))
+ (cl-letf (((symbol-function 'pearl--all-teams) (lambda () '()))
+ ((symbol-function 'pearl--validate-issue-filter) (lambda (_) t))
+ ((symbol-function 'pearl--build-issue-filter)
+ (lambda (_) '(("state" ("type" ("nin" . ["completed"]))))))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'pearl--customview-update-async)
+ (lambda (_view-id input cb)
+ (setq captured-input input)
+ (funcall cb (list :success t
+ :view '((id . "view-id")
+ (shared . :json-false)))))))
+ (pearl-sync-saved-query-to-linear "Q")
+ (let ((shared-value (cdr (assoc "shared" captured-input))))
+ (should (eq :json-false shared-value))))))
+
+;;; Cache-shape parity — pearl-get-teams-async and pearl--all-teams must agree
+
+(ert-deftest test-pearl-get-teams-async-query-fetches-key ()
+ "`pearl-get-teams-async' must fetch `key' so it can't poison the cache.
+Both `pearl-get-teams-async' and `pearl--all-teams' write
+`pearl--cache-teams', and downstream callers read `key' from cached
+teams. If the async path queried only `id name', a fetch sequence
+async→sync would leave the cache without `key' and silently break
+team-key lookups."
+ (let (captured-query)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (query &rest _) (setq captured-query query))))
+ (pearl-get-teams-async)
+ (should captured-query)
+ (should (string-match-p "key" captured-query)))))
+
+(provide 'test-pearl-saved-query-sync)
+;;; test-pearl-saved-query-sync.el ends here