;;; test-integration-acceptance.el --- End-to-end acceptance flow -*- 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: ;; 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