aboutsummaryrefslogtreecommitdiff
path: root/tests/test-integration-acceptance.el
blob: f83c89182a0418bb2d1739fe06974dbe26589e98 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
;;; test-integration-acceptance.el --- End-to-end acceptance flow -*- 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:

;; End-to-end acceptance flow exercising the integration contract between the
;; query implementation and the org-representation implementation.  The only
;; stub is the single GraphQL chokepoint (`pearl--graphql-request-async'): a
;; dispatcher routes by operation and returns canned, json-read-shaped data, so
;; every layer above the wire — filter compilation, result classification,
;; normalization, sorting, rendering, source-header round-trip, in-place merge,
;; the dirty-buffer guard, the conflict-gated description sync, comment append,
;; and a field-setter command — runs for real against a temp active file.
;;
;; Components integrated (all real unless noted):
;; - pearl-run-saved-query -> --build-issue-filter -> --query-issues-async
;; - --query-issues-async -> --graphql-request-async (MOCKED at the wire)
;; - --render-query-result -> --normalize-issue -> --sort-issues
;; - --update-org-from-issues (real disk write + buffer surface, surface MOCKED)
;; - --read-active-source / --build-org-content header round-trip
;; - pearl-refresh-current-view -> --merge-query-result (in-place merge)
;; - pearl-sync-current-issue -> --sync-decision -> --update-issue-description-async
;; - pearl-add-comment -> --create-comment-async -> --append-comment-to-issue
;; - pearl-set-priority -> --push-issue-field -> --update-issue-async

;;; Code:

(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
(require 'cl-lib)

(defvar test-integration--ops nil
  "Accumulator of GraphQL operations the dispatcher saw, newest first.")

(defun test-integration--nodes ()
  "Two raw issue nodes, the shape `--normalize-issue' reads."
  (list
   '((id . "u-eng-1") (identifier . "ENG-1") (title . "First issue")
     (description . "Original body.") (priority . 2)
     (state (name . "Todo") (type . "unstarted"))
     (url . "https://linear.app/x/ENG-1"))
   '((id . "u-eng-2") (identifier . "ENG-2") (title . "Second issue")
     (description . "More text.") (priority . 1)
     (state (name . "In Progress") (type . "started"))
     (url . "https://linear.app/x/ENG-2"))))

(defun test-integration--op (query)
  "Classify QUERY into the operation it represents.
`commentCreate' and `issueUpdate' are matched before the broader patterns
because the description-update mutation contains both `IssueDescription' and
`issueUpdate' in its text."
  (cond ((string-match-p "commentCreate" query) 'comment-create)
        ((string-match-p "issueUpdate" query) 'issue-update)
        ((string-match-p "issues(filter:" query) 'list)
        ((string-match-p "issue(id:" query) 'fetch-desc)
        (t 'unknown)))

(defun test-integration--response (query)
  "Canned, json-read-shaped data for QUERY's operation."
  (pcase (test-integration--op query)
    ('list `((data (issues (nodes . ,(vconcat (test-integration--nodes)))
                           (pageInfo (hasNextPage . :json-false)
                                     (endCursor . "c"))))))
    ('fetch-desc '((data (issue (description . "Original body.")
                                (updatedAt . "2026-05-24T00:00:00.000Z")))))
    ('comment-create '((data (commentCreate
                              (success . t)
                              (comment (id . "cnew") (body . "looks good")
                                       (createdAt . "2026-05-24T15:00:00.000Z")
                                       (user (name . "Craig")))))))
    ('issue-update '((data (issueUpdate
                            (success . t)
                            (issue (id . "u-eng-1")
                                   (updatedAt . "2026-05-24T16:00:00.000Z"))))))
    (_ nil)))

(defun test-integration--dispatch (query _variables success-fn _error-fn)
  "Stand-in for `--graphql-request-async': record QUERY's op and reply canned."
  (push (test-integration--op query) test-integration--ops)
  (funcall success-fn (test-integration--response query)))

(defmacro test-integration--with-env (&rest body)
  "Run BODY against a temp active file with the GraphQL wire stubbed."
  (declare (indent 0))
  `(let* ((tmp (make-temp-file "pearl-itest" nil ".org"))
          (pearl-org-file-path tmp)
          (pearl-api-key "test-key")
          (pearl-fold-after-update nil)
          (pearl-saved-queries
           '(("Open" :filter (:open t) :sort priority :order asc)
             ("Bugs" :filter (:labels ("bug") :open t))))
          (test-integration--ops nil))
     (when (find-buffer-visiting tmp) (kill-buffer (find-buffer-visiting tmp)))
     (cl-letf (((symbol-function 'pearl--graphql-request-async)
                #'test-integration--dispatch)
               ;; no windows in batch; keep the surface step a no-op
               ((symbol-function 'pearl--surface-buffer) (lambda (b) b)))
       (unwind-protect
           (progn ,@body)
         (let ((buf (find-buffer-visiting tmp)))
           (when buf
             (with-current-buffer buf (set-buffer-modified-p nil))
             (kill-buffer buf)))
         (ignore-errors (delete-file tmp))))))

(ert-deftest test-integration-run-saved-query-renders-active-file ()
  "A saved query fetches, renders to the active file with a source header, and surfaces a buffer."
  (test-integration--with-env
    (pearl-run-saved-query "Open")
    (let ((buf (find-buffer-visiting pearl-org-file-path)))
      (should (buffer-live-p buf))
      (with-current-buffer buf
        (goto-char (point-min))
        (should (re-search-forward "^#\\+LINEAR-SOURCE: " nil t))
        (goto-char (point-min))
        (should (re-search-forward "^#\\+LINEAR-COUNT: 2$" nil t))
        (goto-char (point-min))
        (should (re-search-forward "First issue" nil t))
        (goto-char (point-min))
        (should (re-search-forward "Second issue" nil t))
        ;; the serialized source reads back as the filter source
        (should (eq 'filter (plist-get (pearl--read-active-source) :type)))))
    ;; the fetch went through the real query chokepoint
    (should (memq 'list test-integration--ops))))

(ert-deftest test-integration-refresh-reruns-recorded-source ()
  "Refresh reads the recorded source from the buffer and merges a re-fetch in place."
  (test-integration--with-env
    (pearl-run-saved-query "Open")
    (with-current-buffer (find-buffer-visiting pearl-org-file-path)
      (setq test-integration--ops nil)
      (pearl-refresh-current-view)
      ;; refresh issued a fresh list query for the same source
      (should (memq 'list test-integration--ops))
      ;; both issues survive the in-place merge
      (goto-char (point-min))
      (should (re-search-forward "First issue" nil t))
      (goto-char (point-min))
      (should (re-search-forward "Second issue" nil t)))))

(ert-deftest test-integration-switch-source-protects-dirty-buffer ()
  "Switching to a different saved query with unsaved edits does not overwrite them."
  (test-integration--with-env
    (pearl-run-saved-query "Open")
    (with-current-buffer (find-buffer-visiting pearl-org-file-path)
      (goto-char (point-max))
      (insert "\nUNSAVED LOCAL EDIT\n")
      (should (buffer-modified-p))
      ;; the switch must defer rather than clobber the dirty buffer
      (pearl-run-saved-query "Bugs")
      (goto-char (point-min))
      (should (re-search-forward "UNSAVED LOCAL EDIT" nil t)))))

(ert-deftest test-integration-issue-commands-from-subtree ()
  "From inside a rendered issue subtree: syncing an edited body pushes, adding a comment inserts it, and setting priority drives a field update — all through the real request path."
  (test-integration--with-env
    (pearl-run-saved-query "Open")
    (with-current-buffer (find-buffer-visiting pearl-org-file-path)
      ;; --- sync a description edit (remote unchanged -> clean push) ---
      (goto-char (point-min))
      (re-search-forward "First issue")
      (re-search-forward "Original body\\.")
      (insert " EDITED")
      (setq test-integration--ops nil)
      (pearl-sync-current-issue)
      (should (memq 'fetch-desc test-integration--ops))
      (should (memq 'issue-update test-integration--ops))
      ;; --- add a comment under the same issue ---
      (goto-char (point-min))
      (re-search-forward "First issue")
      (setq test-integration--ops nil)
      (pearl-add-comment "looks good")
      (should (memq 'comment-create test-integration--ops))
      (goto-char (point-min))
      (should (re-search-forward "^\\*\\*\\* Comments$" nil t))
      (should (re-search-forward "looks good" nil t))
      ;; --- set priority from inside the subtree ---
      (goto-char (point-min))
      (re-search-forward "First issue")
      (setq test-integration--ops nil)
      (pearl-set-priority "Urgent")
      (should (memq 'issue-update test-integration--ops)))))

(provide 'test-integration-acceptance)
;;; test-integration-acceptance.el ends here