;;; test-integration-duet-transfer.el --- Real-rsync transfer integration -*- 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: ;; Integration tests that exercise the whole transfer engine against a real ;; local rsync on temporary directories: file and directory copies, the ;; delete-after-success move, and the failed-move-preserves-source guarantee. ;; ;; Components integrated: ;; - duet--run-transfer / duet--launch-process (real make-process) ;; - rsync (REAL, local, version-independent flags) ;; - duet--transfer-sentinel / duet--process-result / duet--transfer-handle-result ;; - duet--finalize-move (real delete on success) ;; ;; Tagged :slow, so `make test' skips them and `make test-all' runs them. ;;; Code: (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) (defmacro duet-itest--with-clean-engine (&rest body) "Run BODY with a fresh engine and the real launcher, cancelling timers after." (declare (indent 0)) `(let ((duet--transfer-id-counter 0) (duet--transfers nil) (duet--transfer-queue nil) (duet--log-render-timer nil) (duet--refresh-pending nil) (duet--refresh-timer nil) (duet-transfer-stall-timeout nil) (duet-max-concurrent-transfers 1)) (unwind-protect (progn ,@body) (when (timerp duet--log-render-timer) (cancel-timer duet--log-render-timer)) (when (timerp duet--refresh-timer) (cancel-timer duet--refresh-timer))))) (defun duet-itest--wait (tr &optional timeout) "Block until transfer TR reaches a terminal status or TIMEOUT seconds pass." (let ((deadline (+ (float-time) (or timeout 15)))) (while (and (not (memq (duet-transfer-status tr) duet--terminal-statuses)) (< (float-time) deadline)) (accept-process-output (duet-transfer-process tr) 0.05)) (duet-transfer-status tr))) (defun duet-itest--tmpdir () "Create and return a fresh temporary directory." (file-name-as-directory (make-temp-file "duet-itest-" t))) (defun duet-itest--write (path contents) "Write CONTENTS into PATH, creating parent directories." (make-directory (file-name-directory path) t) (with-temp-file path (insert contents))) (ert-deftest test-integration-duet-rsync-copies-file () :tags '(:slow) (duet-itest--with-clean-engine (let* ((src (duet-itest--tmpdir)) (dst (duet-itest--tmpdir)) (file (expand-file-name "hello.txt" src))) (unwind-protect (progn (duet-itest--write file "payload\n") (let ((tr (duet--run-transfer (duet--transfer-spec (list file) dst)))) (should (eq 'done (duet-itest--wait tr))) (should (= 0 (duet-transfer-exit tr))) (should (file-exists-p (expand-file-name "hello.txt" dst))) (should (duet-transfer-cleanup-verified tr)))) (delete-directory src t) (delete-directory dst t))))) (ert-deftest test-integration-duet-rsync-copies-directory () :tags '(:slow) (duet-itest--with-clean-engine (let* ((root (duet-itest--tmpdir)) (dst (duet-itest--tmpdir)) (tree (expand-file-name "tree" root))) (unwind-protect (progn (duet-itest--write (expand-file-name "a/one.txt" tree) "1") (duet-itest--write (expand-file-name "b/two.txt" tree) "2") (let ((tr (duet--run-transfer (duet--transfer-spec (list tree) dst)))) (should (eq 'done (duet-itest--wait tr))) (should (file-exists-p (expand-file-name "tree/a/one.txt" dst))) (should (file-exists-p (expand-file-name "tree/b/two.txt" dst))))) (delete-directory root t) (delete-directory dst t))))) (ert-deftest test-integration-duet-move-deletes-source-on-success () :tags '(:slow) (duet-itest--with-clean-engine (let* ((src (duet-itest--tmpdir)) (dst (duet-itest--tmpdir)) (file (expand-file-name "movable.txt" src))) (unwind-protect (progn (duet-itest--write file "move me\n") (let ((tr (duet--run-transfer (duet--transfer-spec (list file) dst) t))) (should (eq 'done (duet-itest--wait tr))) (should (file-exists-p (expand-file-name "movable.txt" dst))) (should-not (file-exists-p file)))) (delete-directory src t) (delete-directory dst t))))) (ert-deftest test-integration-duet-failed-move-preserves-source () :tags '(:slow) (when (zerop (user-uid)) (ert-skip "root bypasses directory permissions")) (duet-itest--with-clean-engine (let* ((src (duet-itest--tmpdir)) (dst (duet-itest--tmpdir)) (file (expand-file-name "keep.txt" src))) (unwind-protect (progn (duet-itest--write file "must survive\n") ;; Read+execute only: rsync cannot create files in the destination. (set-file-modes dst #o500) (let ((tr (duet--run-transfer (duet--transfer-spec (list file) dst) t))) (should (eq 'failed (duet-itest--wait tr))) (should (/= 0 (duet-transfer-exit tr))) ;; The copy failed, so the move never deleted the source. (should (file-exists-p file)))) (set-file-modes dst #o700) (delete-directory src t) (delete-directory dst t))))) (ert-deftest test-integration-duet-kill-process-terminates-live () :tags '(:slow) (duet-itest--with-clean-engine (let* ((proc (make-process :name "duet-kill-test" :command '("sleep" "30") :noquery t)) (tr (duet-transfer-create :id 1 :process proc))) (should (process-live-p proc)) (duet--kill-process tr) (should-not (process-live-p proc))))) (ert-deftest test-integration-duet-process-result-classifies-signal () :tags '(:slow) (let ((proc (make-process :name "duet-signal-test" :command '("sleep" "30") :noquery t))) (interrupt-process proc) (let ((deadline (+ (float-time) 5))) (while (and (process-live-p proc) (< (float-time) deadline)) (accept-process-output proc 0.05))) (should (eq 'signal (process-status proc))) (should (eq :signal (car (duet--process-result proc)))))) (provide 'test-integration-duet-transfer) ;;; test-integration-duet-transfer.el ends here