aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-pearl-accounts.el481
1 files changed, 481 insertions, 0 deletions
diff --git a/tests/test-pearl-accounts.el b/tests/test-pearl-accounts.el
new file mode 100644
index 0000000..c0514a8
--- /dev/null
+++ b/tests/test-pearl-accounts.el
@@ -0,0 +1,481 @@
+;;; test-pearl-accounts.el --- Tests for pearl multi-account support -*- 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:
+
+;; Unit tests for the multi-account layer: the `:api-key-source' credential
+;; resolver, `pearl--resolve-account', and `pearl--current-account-context'.
+;; Live auth-source and the environment are stubbed so the tests need no real
+;; credentials and never touch the user's `~/.authinfo.gpg'.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; :api-key-source resolver
+
+(ert-deftest test-pearl-resolve-api-key-source-literal ()
+ "A `:literal' source returns the embedded key verbatim."
+ (should (string-equal "lin_lit_123"
+ (pearl--resolve-api-key-source '(:literal "lin_lit_123") "work"))))
+
+(ert-deftest test-pearl-resolve-api-key-source-env-present ()
+ "An `:env' source reads the named environment variable."
+ (let ((process-environment (cons "PEARL_TEST_KEY=lin_env_abc" process-environment)))
+ (should (string-equal "lin_env_abc"
+ (pearl--resolve-api-key-source '(:env "PEARL_TEST_KEY") "work")))))
+
+(ert-deftest test-pearl-resolve-api-key-source-env-missing-errors-names-account ()
+ "An unset `:env' variable errors, and the message names the account."
+ (let ((process-environment (list "PEARL_UNRELATED=1"))) ; var deliberately absent
+ (let ((err (should-error
+ (pearl--resolve-api-key-source '(:env "PEARL_NOPE_VAR") "work")
+ :type 'error)))
+ (should (string-match-p "work" (error-message-string err))))))
+
+(ert-deftest test-pearl-resolve-api-key-source-auth-source ()
+ "An `:auth-source' source returns the secret auth-source yields."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) (list (list :secret "lin_authinfo_xyz")))))
+ (should (string-equal "lin_authinfo_xyz"
+ (pearl--resolve-api-key-source
+ '(:auth-source :host "api.linear.app" :user "work") "work")))))
+
+(ert-deftest test-pearl-resolve-api-key-source-auth-source-secret-function ()
+ "auth-source may hand back the secret as a thunk; the resolver funcalls it."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) (list (list :secret (lambda () "lin_thunk_secret"))))))
+ (should (string-equal "lin_thunk_secret"
+ (pearl--resolve-api-key-source
+ '(:auth-source :host "api.linear.app" :user "work") "work")))))
+
+(ert-deftest test-pearl-resolve-api-key-source-auth-source-missing-errors-names-account ()
+ "auth-source finding nothing errors with the account name, not lookup internals."
+ (cl-letf (((symbol-function 'auth-source-search) (lambda (&rest _) nil)))
+ (let ((err (should-error
+ (pearl--resolve-api-key-source
+ '(:auth-source :host "api.linear.app" :user "personal") "personal")
+ :type 'error)))
+ (should (string-match-p "personal" (error-message-string err))))))
+
+(ert-deftest test-pearl-resolve-api-key-source-unknown-form-errors ()
+ "An unrecognized source tag errors rather than silently yielding nil."
+ (should-error (pearl--resolve-api-key-source '(:bogus "x") "work") :type 'error))
+
+;;; pearl--resolve-account
+
+(ert-deftest test-pearl-resolve-account-returns-full-context ()
+ "A named account resolves to a context plist with every field populated."
+ (let ((pearl-accounts
+ '(("work" :api-key-source (:literal "lin_work_key")
+ :org-file "/tmp/work-linear.org"
+ :default-team-id "TEAM_WORK"
+ :url "https://example.test/graphql"))))
+ (let ((ctx (pearl--resolve-account "work")))
+ (should (string-equal "work" (plist-get ctx :name)))
+ (should (string-equal "lin_work_key" (plist-get ctx :api-key)))
+ (should (string-equal "/tmp/work-linear.org" (plist-get ctx :org-file)))
+ (should (string-equal "TEAM_WORK" (plist-get ctx :default-team-id)))
+ (should (string-equal "https://example.test/graphql" (plist-get ctx :url))))))
+
+(ert-deftest test-pearl-resolve-account-expands-org-file ()
+ "A `~'-relative `:org-file' is expanded to an absolute path in the context."
+ (let ((pearl-accounts
+ '(("home" :api-key-source (:literal "k") :org-file "~/pearl-home.org"))))
+ (should (string-equal (expand-file-name "~/pearl-home.org")
+ (plist-get (pearl--resolve-account "home") :org-file)))))
+
+(ert-deftest test-pearl-resolve-account-url-defaults-to-graphql-endpoint ()
+ "An account without `:url' inherits the package's default Linear endpoint."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "k") :org-file "/tmp/w.org")))
+ (pearl-graphql-url "https://api.linear.app/graphql"))
+ (should (string-equal "https://api.linear.app/graphql"
+ (plist-get (pearl--resolve-account "work") :url)))))
+
+(ert-deftest test-pearl-resolve-account-unknown-name-errors ()
+ "Resolving a name absent from `pearl-accounts' errors."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "k") :org-file "/tmp/w.org"))))
+ (should-error (pearl--resolve-account "ghost") :type 'error)))
+
+(ert-deftest test-pearl-resolve-account-missing-key-errors-names-account ()
+ "A bad credential source surfaces as an account-named error from resolve."
+ (let ((pearl-accounts
+ '(("work" :api-key-source (:auth-source :host "api.linear.app" :user "work")
+ :org-file "/tmp/w.org"))))
+ (cl-letf (((symbol-function 'auth-source-search) (lambda (&rest _) nil)))
+ (let ((err (should-error (pearl--resolve-account "work") :type 'error)))
+ (should (string-match-p "work" (error-message-string err)))))))
+
+(ert-deftest test-pearl-resolve-account-does-not-mutate-global-key ()
+ "Resolving an account returns its key in the context, never `setq's the global."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "lin_work") :org-file "/tmp/w.org")))
+ (pearl-api-key "lin_legacy_global"))
+ (pearl--resolve-account "work")
+ (should (string-equal "lin_legacy_global" pearl-api-key))))
+
+;;; pearl--current-account-context
+
+(ert-deftest test-pearl-current-account-context-accounts-nil-returns-nil ()
+ "With no accounts configured the context is nil — legacy single-account mode."
+ (let ((pearl-accounts nil)
+ (pearl-active-account nil)
+ (pearl-default-account nil))
+ (should (null (pearl--current-account-context)))))
+
+(ert-deftest test-pearl-current-account-context-resolves-default-on-first-need ()
+ "Accounts set, none active: the default is resolved and becomes active."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-default-account "home")
+ (pearl-active-account nil))
+ (let ((ctx (pearl--current-account-context)))
+ (should (string-equal "home" (plist-get ctx :name)))
+ (should (string-equal "home" pearl-active-account)))))
+
+(ert-deftest test-pearl-current-account-context-no-default-errors ()
+ "Accounts set with neither an active nor a default account errors."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-default-account nil)
+ (pearl-active-account nil))
+ (should-error (pearl--current-account-context) :type 'error)))
+
+(ert-deftest test-pearl-current-account-context-uses-active-over-default ()
+ "An explicit active account wins over the configured default."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-default-account "home")
+ (pearl-active-account "work"))
+ (should (string-equal "work" (plist-get (pearl--current-account-context) :name)))))
+
+;;; pearl--with-account-context macro
+
+(ert-deftest test-pearl-with-account-context-binds-globals ()
+ "Inside the macro the legacy globals reflect the context's values."
+ (let ((pearl-api-key "legacy") (pearl-graphql-url "legacy-url")
+ (pearl-org-file-path "/legacy.org") (pearl-default-team-id "LEGACY"))
+ (pearl--with-account-context
+ '(:name "work" :api-key "kw" :url "https://w.test/gql"
+ :org-file "/tmp/w.org" :default-team-id "TEAM_W")
+ (should (string-equal "kw" pearl-api-key))
+ (should (string-equal "https://w.test/gql" pearl-graphql-url))
+ (should (string-equal "/tmp/w.org" pearl-org-file-path))
+ (should (string-equal "TEAM_W" pearl-default-team-id)))))
+
+(ert-deftest test-pearl-with-account-context-nil-is-passthrough ()
+ "A nil context leaves the globals untouched (legacy single-account mode)."
+ (let ((pearl-api-key "legacy") (pearl-org-file-path "/legacy.org"))
+ (pearl--with-account-context nil
+ (should (string-equal "legacy" pearl-api-key))
+ (should (string-equal "/legacy.org" pearl-org-file-path)))))
+
+(ert-deftest test-pearl-with-account-context-restores-after ()
+ "The globals are restored to their prior values after the macro body."
+ (let ((pearl-api-key "legacy") (pearl-org-file-path "/legacy.org"))
+ (pearl--with-account-context
+ '(:name "work" :api-key "kw" :url "u" :org-file "/tmp/w.org")
+ (ignore))
+ (should (string-equal "legacy" pearl-api-key))
+ (should (string-equal "/legacy.org" pearl-org-file-path))))
+
+;;; Dispatch-time snapshot (HP1)
+
+(ert-deftest test-pearl-request-async-callback-runs-under-dispatch-context ()
+ "request-async re-establishes the dispatch-time context around its callback.
+A switch to another account after dispatch must not bleed into the callback —
+the callback sees the key and org-file of the account that was active when the
+request fired."
+ (let ((pearl-accounts
+ (list (list "work" :api-key-source '(:literal "kw") :org-file "/tmp/w.org")
+ (list "home" :api-key-source '(:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-default-account "work")
+ (pearl-active-account "work")
+ (pearl--account-context nil)
+ (captured nil) (seen-key nil) (seen-file nil))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args) (setq captured (plist-get args :success)))))
+ (pearl--graphql-request-async
+ "q" nil
+ (lambda (_data) (setq seen-key pearl-api-key
+ seen-file pearl-org-file-path))))
+ ;; The user switches accounts before the response lands.
+ (setq pearl-active-account "home")
+ (funcall captured :data '((data)))
+ (should (string-equal "kw" seen-key))
+ (should (string-equal (expand-file-name "/tmp/w.org") seen-file))))
+
+(ert-deftest test-pearl-request-async-uses-active-account-key-at-dispatch ()
+ "With accounts configured, request-async builds headers from the active account."
+ (let ((pearl-accounts
+ (list (list "work" :api-key-source '(:literal "lin_work_dispatch")
+ :org-file "/tmp/w.org")))
+ (pearl-default-account "work")
+ (pearl-active-account "work")
+ (pearl--account-context nil)
+ (seen-auth nil))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (setq seen-auth (cdr (assoc "Authorization" (plist-get args :headers)))))))
+ (pearl--graphql-request-async "q" nil #'ignore))
+ (should (string-equal "lin_work_dispatch" seen-auth))))
+
+(ert-deftest test-pearl-request-async-legacy-mode-uses-global-key ()
+ "With no accounts configured, request-async uses the legacy global key unchanged."
+ (let ((pearl-accounts nil)
+ (pearl-active-account nil)
+ (pearl-default-account nil)
+ (pearl-api-key "lin_legacy_global")
+ (pearl--account-context nil)
+ (seen-auth nil))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (setq seen-auth (cdr (assoc "Authorization" (plist-get args :headers)))))))
+ (pearl--graphql-request-async "q" nil #'ignore))
+ (should (string-equal "lin_legacy_global" seen-auth))))
+
+;;; #+LINEAR-ACCOUNT header + buffer ownership
+
+(ert-deftest test-pearl-read-buffer-account-present ()
+ "The buffer-account reader returns the #+LINEAR-ACCOUNT name."
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-ACCOUNT: work\n#+LINEAR-SOURCE: (:type filter)\n")
+ (should (string-equal "work" (pearl--read-buffer-account)))))
+
+(ert-deftest test-pearl-read-buffer-account-absent ()
+ "A buffer with no #+LINEAR-ACCOUNT marker reads as nil."
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-SOURCE: (:type filter)\n")
+ (should (null (pearl--read-buffer-account)))))
+
+(ert-deftest test-pearl-build-org-content-emits-account-header ()
+ "When an account name is supplied, the content carries #+LINEAR-ACCOUNT."
+ (let ((content (pearl--build-org-content nil nil nil nil "work")))
+ (should (string-match-p "^#\\+LINEAR-ACCOUNT: work$" content))))
+
+(ert-deftest test-pearl-build-org-content-omits-account-header-in-legacy ()
+ "With no account name the content omits #+LINEAR-ACCOUNT (legacy file)."
+ (let ((content (pearl--build-org-content nil nil nil nil nil)))
+ (should-not (string-match-p "LINEAR-ACCOUNT" content))))
+
+;;; pearl--require-account-context buffer guard
+
+(ert-deftest test-pearl-require-account-context-legacy-returns-nil ()
+ "In legacy mode the guard is a no-op, even for a mutation."
+ (let ((pearl-accounts nil) (pearl-active-account nil) (pearl-default-account nil))
+ (with-temp-buffer
+ (should (null (pearl--require-account-context t))))))
+
+(ert-deftest test-pearl-require-account-context-matching-account-proceeds ()
+ "A buffer owned by the active account passes the guard and yields its context."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work"))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n")
+ (let ((ctx (pearl--require-account-context t)))
+ (should (string-equal "work" (plist-get ctx :name)))))))
+
+(ert-deftest test-pearl-require-account-context-wrong-account-refuses-mutation ()
+ "A mutation from another account's file refuses, naming both accounts."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home"))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n")
+ (let ((err (should-error (pearl--require-account-context t) :type 'error)))
+ (should (string-match-p "work" (error-message-string err)))
+ (should (string-match-p "home" (error-message-string err)))))))
+
+(ert-deftest test-pearl-require-account-context-wrong-account-refuses-nonmutation ()
+ "Even a non-mutating buffer command refuses against another account's file —
+fetching the active account's issues into it would cross workspaces."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home"))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n")
+ (should-error (pearl--require-account-context nil) :type 'error))))
+
+(ert-deftest test-pearl-require-account-context-unmarked-mutation-refuses ()
+ "An unmarked buffer refuses a mutation until a refresh stamps ownership."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work"))
+ (with-temp-buffer
+ (insert "#+LINEAR-SOURCE: (:type filter)\n")
+ (should-error (pearl--require-account-context t) :type 'error))))
+
+(ert-deftest test-pearl-require-account-context-unmarked-nonmutation-proceeds ()
+ "An unmarked buffer lets a render/list command through (it stamps ownership)."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work"))
+ (with-temp-buffer
+ (insert "#+LINEAR-SOURCE: (:type filter)\n")
+ (let ((ctx (pearl--require-account-context nil)))
+ (should (string-equal "work" (plist-get ctx :name)))))))
+
+;;; Guard wiring — commands refuse before any API call
+
+(ert-deftest test-pearl-save-issue-refuses-wrong-account-buffer ()
+ "`pearl-save-issue' from another account's buffer errors before touching the API."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home")
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n#+LINEAR-SOURCE: (:type filter)\n"
+ "* View\n** TODO thing\n:PROPERTIES:\n:LINEAR-ID: abc\n:END:\n")
+ (org-mode)
+ (goto-char (point-max))
+ (should-error (pearl-save-issue) :type 'error)
+ (should-not api-called)))))
+
+(ert-deftest test-pearl-edit-description-refuses-unmarked-buffer ()
+ "`pearl-edit-description' refuses to mutate an unmarked buffer under accounts."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work")
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (with-temp-buffer
+ (insert "#+LINEAR-SOURCE: (:type filter)\n* View\n** TODO thing\n"
+ ":PROPERTIES:\n:LINEAR-ID: abc\n:END:\n")
+ (org-mode)
+ (goto-char (point-max))
+ (should-error (pearl-edit-description) :type 'error)
+ (should-not api-called)))))
+
+;;; Cache isolation + switch + indicator
+
+(ert-deftest test-pearl-clear-account-scoped-state-nils-every-cache ()
+ "The helper clears every account-scoped cache it names."
+ (let ((pearl--cache-teams '(x)) (pearl--cache-states '(x))
+ (pearl--cache-team-collections '(x)) (pearl--cache-views '(x))
+ (pearl--cache-viewer '(:id "v")))
+ (pearl--clear-account-scoped-state)
+ (should (null pearl--cache-teams))
+ (should (null pearl--cache-states))
+ (should (null pearl--cache-team-collections))
+ (should (null pearl--cache-views))
+ (should (null pearl--cache-viewer))))
+
+(ert-deftest test-pearl-switch-account-sets-active-and-clears-caches ()
+ "Switching accounts sets `pearl-active-account' and clears the lookup caches."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w-nope.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h-nope.org")))
+ (pearl-active-account "work")
+ (pearl--cache-teams '(stale)) (pearl--cache-viewer '(:id "stale")))
+ (pearl-switch-account "home")
+ (should (string-equal "home" pearl-active-account))
+ (should (null pearl--cache-teams))
+ (should (null pearl--cache-viewer))))
+
+(ert-deftest test-pearl-switch-account-unknown-name-errors ()
+ "Switching to an unconfigured account errors (via the resolver)."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work"))
+ (should-error (pearl-switch-account "ghost") :type 'error)))
+
+(ert-deftest test-pearl-mode-line-lighter-shows-active-account ()
+ "The lighter names the active account when accounts are configured."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work"))
+ (should (string-equal " Pearl[work]" (pearl--mode-line-lighter)))))
+
+(ert-deftest test-pearl-mode-line-lighter-plain-in-legacy ()
+ "Without accounts the lighter is the plain \" Pearl\"."
+ (let ((pearl-accounts nil) (pearl-active-account nil))
+ (should (string-equal " Pearl" (pearl--mode-line-lighter)))))
+
+;;; Saved-query :account guard
+
+(ert-deftest test-pearl-run-saved-query-refuses-other-account-before-network ()
+ "A saved query tagged for another account refuses before any compilation or fetch."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home")
+ (pearl-saved-queries '(("Work bugs" :account "work" :filter (:assignee :me))))
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (let ((err (should-error (pearl-run-saved-query "Work bugs") :type 'error)))
+ (should (string-match-p "work" (error-message-string err))))
+ (should-not api-called))))
+
+(ert-deftest test-pearl-run-saved-query-account-match-proceeds-to-fetch ()
+ "A saved query tagged for the active account compiles and dispatches."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work")
+ (pearl-saved-queries '(("Work bugs" :account "work" :filter (:assignee :me))))
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (pearl-run-saved-query "Work bugs")
+ (should api-called))))
+
+(ert-deftest test-pearl-run-saved-query-account-tag-ignored-in-legacy ()
+ "With no accounts configured, an `:account' tag is inert (shared query)."
+ (let ((pearl-accounts nil) (pearl-active-account nil) (pearl-default-account nil)
+ (pearl-api-key "lin_legacy")
+ (pearl-saved-queries '(("Work bugs" :account "work" :filter (:assignee :me))))
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (pearl-run-saved-query "Work bugs")
+ (should api-called))))
+
+;;; pearl-load-api-key-from-env legacy-only
+
+(ert-deftest test-pearl-load-api-key-from-env-refuses-under-accounts ()
+ "The env loader refuses once accounts are configured, to not bypass the resolver."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org"))))
+ (should-error (pearl-load-api-key-from-env) :type 'error)))
+
+;;; Refresh stamps ownership on a legacy file (closes the guard's "refresh first" loop)
+
+(ert-deftest test-pearl-update-source-header-stamps-missing-account ()
+ "Refreshing an unmarked file under accounts mode stamps #+LINEAR-ACCOUNT,
+so a later mutation isn't permanently refused with \"refresh it under an
+account first\" — the refresh the guard tells the user to run must resolve."
+ (let ((pearl--account-context '(:name "work" :api-key "kw" :org-file "/tmp/w.org")))
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-SOURCE: (:type filter)\n"
+ "#+LINEAR-RUN-AT: 2020-01-01 00:00\n#+LINEAR-COUNT: 0\n"
+ "#+LINEAR-TRUNCATED: no\n* View\n")
+ (pearl--update-source-header 1 nil)
+ (should (string-equal "work" (pearl--read-buffer-account))))))
+
+(ert-deftest test-pearl-update-source-header-leaves-existing-account ()
+ "A file already stamped keeps its marker untouched on refresh."
+ (let ((pearl--account-context '(:name "work" :api-key "kw" :org-file "/tmp/w.org")))
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-ACCOUNT: work\n#+LINEAR-SOURCE: (:type filter)\n"
+ "#+LINEAR-RUN-AT: 2020-01-01 00:00\n#+LINEAR-COUNT: 0\n"
+ "#+LINEAR-TRUNCATED: no\n* View\n")
+ (pearl--update-source-header 1 nil)
+ (should (string-equal "work" (pearl--read-buffer-account)))
+ ;; exactly one marker, not duplicated
+ (should (= 1 (count-matches "^#\\+LINEAR-ACCOUNT:" (point-min) (point-max)))))))
+
+(ert-deftest test-pearl-update-source-header-legacy-mode-no-stamp ()
+ "In legacy mode (no bound context) refresh adds no account marker."
+ (let ((pearl--account-context nil))
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-SOURCE: (:type filter)\n"
+ "#+LINEAR-RUN-AT: 2020-01-01 00:00\n#+LINEAR-COUNT: 0\n"
+ "#+LINEAR-TRUNCATED: no\n* View\n")
+ (pearl--update-source-header 1 nil)
+ (should (null (pearl--read-buffer-account))))))
+
+(provide 'test-pearl-accounts)
+;;; test-pearl-accounts.el ends here