;;; 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 ;; 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 . ;;; 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-stores-view-url-when-given () "The optional VIEW-URL arg lands in the entry as :linear-view-url." (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" "https://linear.app/ws/view/abc")) (let ((spec (cdr (assoc "Q" pearl-saved-queries)))) (should (equal "https://linear.app/ws/view/abc" (plist-get spec :linear-view-url)))))) (ert-deftest test-pearl-save-query-mark-synced-keeps-prior-url-when-omitted () "Omitting VIEW-URL on a re-sync preserves any previously stored URL. Re-sync paths whose API response doesn't carry a URL shouldn't erase a URL the create path already wrote." (let ((pearl-saved-queries (copy-tree '(("Q" :filter (:open t) :linear-view-id "old-id" :linear-view-team-id nil :linear-view-shared :json-false :linear-view-url "https://linear.app/ws/view/old"))))) (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil))) ;; No VIEW-URL passed -- the prior URL should survive. (pearl--save-query-mark-synced "Q" nil nil "new-id")) (let ((spec (cdr (assoc "Q" pearl-saved-queries)))) (should (equal "https://linear.app/ws/view/old" (plist-get spec :linear-view-url)))))) (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)))))) ;;; pearl-publish-current-source (defmacro test-pearl-publish--in-buffer (source-form &rest body) "Run BODY in an org-mode temp buffer carrying SOURCE-FORM as #+LINEAR-SOURCE. SOURCE-FORM is the source plist (or nil for no source header)." (declare (indent 1)) `(with-temp-buffer (when ,source-form (insert (format "#+title: test\n#+LINEAR-SOURCE: %s\n\n" (prin1-to-string ,source-form)))) (org-mode) ,@body)) (ert-deftest test-pearl-publish-current-source-no-source-header-errors () "A buffer with no #+LINEAR-SOURCE signals a clear user-error." (test-pearl-publish--in-buffer nil (should-error (pearl-publish-current-source) :type 'user-error))) (ert-deftest test-pearl-publish-current-source-view-source-errors () "A view source signals user-error -- views are already on Linear." (test-pearl-publish--in-buffer '(:type view :name "Eng dashboard" :id "v1") (should-error (pearl-publish-current-source) :type 'user-error))) (ert-deftest test-pearl-publish-current-source-transient-filter-no-name-errors () "A :type filter with no :name (transient ad-hoc) signals user-error." (test-pearl-publish--in-buffer '(:type filter :filter (:open t)) (should-error (pearl-publish-current-source) :type 'user-error))) (ert-deftest test-pearl-publish-current-source-no-matching-saved-query-errors () "A :type filter source whose name doesn't match any pearl-saved-queries entry errors." (let ((pearl-saved-queries '(("Other" :filter (:open t))))) (test-pearl-publish--in-buffer '(:type filter :name "Gone" :filter (:open t)) (should-error (pearl-publish-current-source) :type 'user-error)))) (ert-deftest test-pearl-publish-current-source-dispatches-to-sync-with-name () "A :type filter source whose name matches dispatches to pearl-sync-saved-query-to-linear." (let ((pearl-saved-queries '(("Mine" :filter (:open t)))) (sync-called-with nil)) (cl-letf (((symbol-function 'pearl-sync-saved-query-to-linear) (lambda (n) (setq sync-called-with n)))) (test-pearl-publish--in-buffer '(:type filter :name "Mine" :filter (:open t)) (pearl-publish-current-source)) (should (equal "Mine" sync-called-with))))) (ert-deftest test-pearl-publish-current-source-empty-name-errors () "A :type filter source with an empty-string :name (degenerate) errors cleanly." (test-pearl-publish--in-buffer '(:type filter :name "" :filter (:open t)) (should-error (pearl-publish-current-source) :type 'user-error))) (ert-deftest test-pearl-publish-current-source-whitespace-name-errors () "A :type filter source with a whitespace-only :name takes the no-name branch rather than falling through to the no-matching-saved-query branch with a visibly-blank name in the error message." (let ((pearl-saved-queries '())) (test-pearl-publish--in-buffer '(:type filter :name " " :filter (:open t)) (let ((err (should-error (pearl-publish-current-source) :type 'user-error))) ;; The message should name the source-has-no-name reason, not the ;; no-matching-query reason. (should (string-match-p "no name" (error-message-string err))))))) ;;; 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))))) ;;; pearl--customview-delete-async (ert-deftest test-pearl-customview-delete-async-success () "A successful `customViewDelete' invokes the callback with `:success' t." (testutil-linear-with-response '((data (customViewDelete (success . t)))) (let (result) (pearl--customview-delete-async "view-uuid" (lambda (r) (setq result r))) (should (eq t (plist-get result :success)))))) (ert-deftest test-pearl-customview-delete-async-failure () "A non-success `customViewDelete' invokes the callback with `:success' nil." (testutil-linear-with-response '((data (customViewDelete (success . :json-false)))) (let (result) (pearl--customview-delete-async "view-uuid" (lambda (r) (setq result r))) (should-not (plist-get result :success))))) ;;; pearl-delete-saved-query — synced-entry second-stage prompt (defun test-pearl--make-yes-no-stub (answers) "Return a `yes-or-no-p' replacement that returns each ANSWERS in order. Each call pops the head; the test fails the call sequence assertion if more prompts fire than answers were prepared for." (let ((remaining answers)) (lambda (&rest _) (if remaining (pop remaining) (error "Unexpected extra yes-or-no-p prompt"))))) (ert-deftest test-pearl-delete-saved-query-synced-yes-yes-calls-customview-delete () "Synced entry, both prompts yes: customViewDelete fires and the local entry is removed." (let ((pearl-saved-queries (copy-tree '(("Q" :filter (:open t) :linear-view-id "view-uuid" :linear-view-team-id nil :linear-view-shared :json-false)))) (delete-called-with nil)) (cl-letf (((symbol-function 'yes-or-no-p) (test-pearl--make-yes-no-stub '(t t))) ((symbol-function 'customize-save-variable) (lambda (_ _) nil)) ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) ((symbol-function 'message) (lambda (&rest _) nil)) ((symbol-function 'pearl--customview-delete-async) (lambda (vid cb) (setq delete-called-with vid) (funcall cb (list :success t))))) (pearl-delete-saved-query "Q") (should (equal "view-uuid" delete-called-with)) (should-not (assoc "Q" pearl-saved-queries))))) (ert-deftest test-pearl-delete-saved-query-synced-yes-no-unlinks-only () "Synced entry, first prompt yes second prompt no: local entry removed, customViewDelete not called." (let ((pearl-saved-queries (copy-tree '(("Q" :filter (:open t) :linear-view-id "view-uuid")))) (delete-called nil)) (cl-letf (((symbol-function 'yes-or-no-p) (test-pearl--make-yes-no-stub '(t nil))) ((symbol-function 'customize-save-variable) (lambda (_ _) nil)) ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) ((symbol-function 'message) (lambda (&rest _) nil)) ((symbol-function 'pearl--customview-delete-async) (lambda (_ _) (setq delete-called t)))) (pearl-delete-saved-query "Q") (should-not delete-called) (should-not (assoc "Q" pearl-saved-queries))))) (ert-deftest test-pearl-delete-saved-query-synced-api-failure-then-delete-anyway () "Synced entry, customViewDelete fails, user accepts delete-anyway prompt: local removed, message names the orphan id." (let ((pearl-saved-queries (copy-tree '(("Q" :filter (:open t) :linear-view-id "ORPHAN-UUID")))) (last-message nil)) (cl-letf (((symbol-function 'yes-or-no-p) (test-pearl--make-yes-no-stub '(t t t))) ; delete? + also-on-Linear? + delete-anyway? ((symbol-function 'customize-save-variable) (lambda (_ _) nil)) ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) ((symbol-function 'message) (lambda (fmt &rest args) (setq last-message (apply #'format fmt args)))) ((symbol-function 'pearl--customview-delete-async) (lambda (_ cb) (funcall cb (list :success nil :error "permission denied"))))) (pearl-delete-saved-query "Q") (should-not (assoc "Q" pearl-saved-queries)) (should (string-match-p "ORPHAN-UUID" last-message)) (should (string-match-p "orphan" last-message))))) (ert-deftest test-pearl-delete-saved-query-synced-api-failure-then-keep () "Synced entry, customViewDelete fails, user rejects delete-anyway: both kept untouched. Verifies the API call actually fires (so the keep branch only runs when the delete-anyway prompt explicitly declines), guarding against a future refactor that silently routes to the unlink branch and skips the API call." (let ((pearl-saved-queries (copy-tree '(("Q" :filter (:open t) :linear-view-id "view-uuid")))) (delete-called-with nil)) (cl-letf (((symbol-function 'yes-or-no-p) (test-pearl--make-yes-no-stub '(t t nil))) ; yes / yes / no on delete-anyway ((symbol-function 'customize-save-variable) (lambda (_ _) nil)) ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) ((symbol-function 'message) (lambda (&rest _) nil)) ((symbol-function 'pearl--customview-delete-async) (lambda (vid cb) (setq delete-called-with vid) (funcall cb (list :success nil :error "permission denied"))))) (pearl-delete-saved-query "Q") (should (equal "view-uuid" delete-called-with)) (should (assoc "Q" pearl-saved-queries))))) (ert-deftest test-pearl-delete-saved-query-local-only-entry-unchanged-behavior () "Local-only entry (no `:linear-view-id'): one yes-or-no-p, no second prompt, no API call." (let ((pearl-saved-queries (copy-tree '(("Q" :filter (:open t))))) (delete-called nil) (prompt-count 0)) (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) (cl-incf prompt-count) t)) ((symbol-function 'customize-save-variable) (lambda (_ _) nil)) ((symbol-function 'message) (lambda (&rest _) nil)) ((symbol-function 'pearl--customview-delete-async) (lambda (_ _) (setq delete-called t)))) (pearl-delete-saved-query "Q") (should (= 1 prompt-count)) (should-not delete-called) (should-not (assoc "Q" pearl-saved-queries))))) (provide 'test-pearl-saved-query-sync) ;;; test-pearl-saved-query-sync.el ends here