aboutsummaryrefslogtreecommitdiff
path: root/tests/test-calendar-sync--batch-report.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-03 09:39:08 -0500
committerCraig Jennings <c@cjennings.net>2026-08-03 09:39:08 -0500
commiteb0c2191b77e795cca7884e0690ea51c515527fd (patch)
treeb6686bca1c7627d24e5fb6fd0b2df525db56d435 /tests/test-calendar-sync--batch-report.el
parent0eb65b6dcc2db54663b39dc7201c3dcf151add74 (diff)
downloaddotemacs-eb0c2191b77e795cca7884e0690ea51c515527fd.tar.gz
dotemacs-eb0c2191b77e795cca7884e0690ea51c515527fd.zip
fix(calendar-sync): run the sync from a timer instead of the agenda hook
The hourly timer armed itself from org-agenda-mode-hook, so a session where I never opened the agenda never synced at all. After a reboot that is every session until the first agenda call. I found it with all three calendar files still frozen at the pre-reboot write, five hours stale. The deferral had a real reason. Feed URLs behind :secret-host live in authinfo.gpg, and starting at load would prompt for a passphrase on a cold gpg-agent. The cost was worse than what it bought. A user timer owns the schedule now, every ten minutes. calendar-sync-auto-start defaults to nil so the editor no longer arms its own. scripts/calendar-sync-run is the batch entry point, and it blocks until every calendar leaves the syncing state. The pipeline is asynchronous end to end, so a batch Emacs that returns early exits zero having written nothing. Installing it on a machine that resolves its feeds through :secret-host surfaced two bugs the inline-url machine could never show: - cj/auth-source-secret-value called auth-source-search behind nothing but a declare-function, which quiets the compiler and loads nothing. An interactive Emacs always has auth-source in by the time anyone calls, so the omission stayed invisible until a batch -Q sync died on a void function. The require is guarded on fboundp. An unconditional one reloads auth-source over whatever is already there, which replaced a caller's stubbed search mid-call and sent an existing test out to the real authinfo. - A synchronous failure in one calendar aborted the whole loop. The async callbacks record their own failures, but they never run when the error lands before a process starts. Resolving a :secret-host feed signals outright on a cold agent. Failures are contained per calendar now, so one bad feed no longer costs the other two. A failed row prints the reason it recorded. Batch Emacs discards the *Messages* buffer the interactive path logs to, so without it the journal shows only "error", with no way to tell a cold agent from a revoked token.
Diffstat (limited to 'tests/test-calendar-sync--batch-report.el')
-rw-r--r--tests/test-calendar-sync--batch-report.el89
1 files changed, 89 insertions, 0 deletions
diff --git a/tests/test-calendar-sync--batch-report.el b/tests/test-calendar-sync--batch-report.el
new file mode 100644
index 00000000..12811200
--- /dev/null
+++ b/tests/test-calendar-sync--batch-report.el
@@ -0,0 +1,89 @@
+;;; test-calendar-sync--batch-report.el --- Batch report output tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `calendar-sync-batch-run-and-report' is what the systemd timer runs, so its
+;; printed rows are the only record that survives the process. Batch Emacs
+;; discards *Messages* at exit, which is where the interactive failure path
+;; logs its reason -- so a failed row has to carry its recorded `:last-error'
+;; in the printed output or the journal shows "error" with no way to tell a
+;; cold gpg-agent from a revoked feed token or a dead network.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib) ;; cl-letf; calendar-sync pulls it in transitively, don't rely on that
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calendar-sync)
+
+(defun test-calendar-sync-batch-report--capture (states results)
+ "Return the report's printed output for STATES and RESULTS.
+STATES is an alist of NAME . PLIST seeded into the state table; RESULTS is
+what `calendar-sync-batch-run' is stubbed to return, so the report is
+exercised without driving a real sync."
+ (let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ (dolist (entry states)
+ (puthash (car entry) (cdr entry) calendar-sync--calendar-states))
+ (cl-letf (((symbol-function 'calendar-sync-batch-run)
+ (lambda (&rest _) results)))
+ (with-output-to-string
+ (calendar-sync-batch-run-and-report)))))
+
+;;; Normal
+
+(ert-deftest test-calendar-sync-batch-report-failed-row-carries-its-reason ()
+ "Normal: a failed calendar prints the recorded `:last-error' reason.
+Without it the journal records only \"error\", and the operator cannot tell a
+cold gpg-agent from a revoked token without re-running the sync by hand."
+ (let ((out (test-calendar-sync-batch-report--capture
+ '(("google" . (:status error :last-error "Decryption failed"))
+ ("proton" . (:status ok)))
+ '(("google" . error) ("proton" . ok)))))
+ (should (string-match-p "google: error" out))
+ (should (string-match-p "Decryption failed" out))))
+
+(ert-deftest test-calendar-sync-batch-report-ok-row-stays-bare ()
+ "Normal: a calendar that synced prints its status and nothing more.
+A stale `:last-error' from an earlier failure must not be appended to a row
+that succeeded this run."
+ (let ((out (test-calendar-sync-batch-report--capture
+ '(("google" . (:status ok :last-error "Decryption failed")))
+ '(("google" . ok)))))
+ (should (string-match-p "google: ok" out))
+ (should-not (string-match-p "Decryption failed" out))))
+
+;;; Boundary
+
+(ert-deftest test-calendar-sync-batch-report-failure-without-reason-still-prints ()
+ "Boundary: a failed row with no recorded reason prints its status alone.
+`never' and `syncing' never record a `:last-error', so the reason lookup has
+to tolerate nil rather than printing \"nil\" or signalling."
+ (let ((out (test-calendar-sync-batch-report--capture
+ '(("google" . (:status syncing)))
+ '(("google" . syncing) ("absent" . never)))))
+ (should (string-match-p "google: syncing" out))
+ (should (string-match-p "absent: never" out))
+ (should-not (string-match-p "nil" out))))
+
+;;; Error
+
+(defun test-calendar-sync-batch-report--exit-code (results)
+ "Return the report's exit code for RESULTS, discarding its printed output."
+ (let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ (cl-letf (((symbol-function 'calendar-sync-batch-run)
+ (lambda (&rest _) results)))
+ (with-temp-buffer
+ (let ((standard-output (current-buffer)))
+ (calendar-sync-batch-run-and-report))))))
+
+(ert-deftest test-calendar-sync-batch-report-exit-code-tracks-failures ()
+ "Error: the return value becomes the process exit code, so it stays 1 on any
+non-ok row and 0 only when every calendar synced. Appending the reason to the
+printed line must not disturb it."
+ (should (equal 1 (test-calendar-sync-batch-report--exit-code '(("google" . error)))))
+ (should (equal 1 (test-calendar-sync-batch-report--exit-code
+ '(("google" . ok) ("proton" . never)))))
+ (should (equal 0 (test-calendar-sync-batch-report--exit-code '(("google" . ok))))))
+
+(provide 'test-calendar-sync--batch-report)
+;;; test-calendar-sync--batch-report.el ends here