From e48e9b75160fae5641ac5818e7e381cbcfdb8a5e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 24 May 2026 15:34:17 -0500 Subject: test: add an end-to-end acceptance flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stubs the single GraphQL chokepoint and drives the full integration path against a temp active file: run a saved query, render with the source header, read the source back, refresh the same source with an in-place merge, switch sources while the buffer is dirty (the edit is preserved, not clobbered), then from inside a rendered issue subtree sync an edited description, add a comment, and set priority. Everything above the wire — filter compilation, normalization, sort, render, header round-trip, merge, the conflict gate, comment append, and the field setter — runs for real, so the query and org-representation layers are exercised together rather than in isolation. 388 tests green (384 unit + 4 integration). --- tests/test-integration-acceptance.el | 200 +++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/test-integration-acceptance.el diff --git a/tests/test-integration-acceptance.el b/tests/test-integration-acceptance.el new file mode 100644 index 0000000..f83c891 --- /dev/null +++ b/tests/test-integration-acceptance.el @@ -0,0 +1,200 @@ +;;; 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 -- cgit v1.2.3