diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-31 07:10:33 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-31 07:10:33 -0500 |
| commit | 394c75b2f92e2a72390b3417e483849bed13a399 (patch) | |
| tree | a5b92603ecb049381015fb7556f25dcf74fa6c21 /tests | |
| parent | 4767d16423ff5c87cf1222603c7b8f1b59af8ef2 (diff) | |
| download | pearl-394c75b2f92e2a72390b3417e483849bed13a399.tar.gz pearl-394c75b2f92e2a72390b3417e483849bed13a399.zip | |
feat(accounts): multi-account support with tests
I added a named-account layer for working more than one Linear workspace from one Emacs.
Before this, everything that identified a workspace was a single global: pearl-api-key, pearl-graphql-url, pearl-org-file-path, and the lookup caches. Nothing stopped a work command from running under personal credentials or a work fetch from landing in the personal file, and switching accounts meant re-customizing the key, team, and file by hand and clearing the cache.
pearl-accounts maps a name to a per-workspace plist (credential source, org file, default team, optional endpoint), and pearl-switch-account makes one active. Account state flows through an explicit context rather than mutating globals. Every request snapshots its account at dispatch through pearl--graphql-request-async and re-establishes it around the callbacks. A switch mid-fetch can't bleed into a request already in flight: the result finishes into the account it was dispatched under. I centralized this in the one request primitive, so the leak surface is a single function instead of every call site.
Rendered files carry a #+LINEAR-ACCOUNT marker, and a buffer guard refuses a command run from one account's file while another is active, naming both, so a work edit can't push under personal credentials. An unmarked legacy file lets reads and refreshes through and acquires its marker on the first refresh. Mutations wait until then. Credentials resolve through auth-source, an env var, or an inline literal, and a resolved key is never persisted through Customize or logged. The active account shows in the mode line. A saved query can carry an :account so it refuses to run under the wrong workspace before any lookup.
With pearl-accounts unset, everything behaves exactly as before, off the legacy globals.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-pearl-accounts.el | 481 |
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 |
