aboutsummaryrefslogtreecommitdiff
path: root/tests/test-duet-backend.el
blob: 629d2ebede4371f2144b04ead94e34f725d9489a (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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
;;; test-duet-backend.el --- Tests for the duet backend registry -*- 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:

;; Tests for the transport-backend registry, scorer, redaction helper,
;; failure-normalizer interface, and the tiered contract-check functions.
;; The machinery is exercised with fake backends defined here; the concrete
;; rsync/TRAMP backends register in Phase 3 once their command builders exist.
;;
;; Each test rebinds `duet--backend-registry' to nil so registrations never
;; leak between tests (no shared mutable state).

;;; Code:

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

(defun test-duet-backend--fake (name score &rest overrides)
  "Build a well-formed fake backend named NAME whose scorer returns SCORE.
OVERRIDES is a plist that wins over the conservative defaults.  It is spliced
ahead of the defaults because cl-defstruct constructors take the leftmost
value for a duplicated keyword."
  (apply #'duet-backend-create
         (append
          overrides
          (list :name name
                :handles (lambda (_src _dst) score)
                :command (lambda (_src _dst _opts)
                           (list :argv (list "echo" (symbol-name name))
                                 :default-directory "/"))
                :capabilities '(:async t)
                :redaction '("\\(password=\\)[^ \n]+")))))

;;; Registry — add, find, replace, order

(ert-deftest test-duet-backend-register-and-find ()
  "A registered backend is retrievable by name."
  (let ((duet--backend-registry nil))
    (duet-register-backend (test-duet-backend--fake 'alpha 10))
    (should (duet-backend-p (duet-backend-by-name 'alpha)))
    (should (= 1 (length (duet-backends))))))

(ert-deftest test-duet-backend-register-replaces-same-name ()
  "Re-registering a name replaces the prior backend rather than duplicating."
  (let ((duet--backend-registry nil))
    (duet-register-backend (test-duet-backend--fake 'alpha 10))
    (duet-register-backend (test-duet-backend--fake 'alpha 99))
    (should (= 1 (length (duet-backends))))
    (should (= 99 (funcall (duet-backend-handles (duet-backend-by-name 'alpha))
                           nil nil)))))

(ert-deftest test-duet-backend-register-newest-first ()
  "The most recently registered backend sorts ahead of older ones."
  (let ((duet--backend-registry nil))
    (duet-register-backend (test-duet-backend--fake 'alpha 10))
    (duet-register-backend (test-duet-backend--fake 'beta 20))
    (should (equal '(beta alpha)
                   (mapcar #'duet-backend-name (duet-backends))))))

;;; Scoring — lowest wins, nil skips, ties break by recency

(ert-deftest test-duet-backend-select-lowest-score-wins ()
  "The backend with the lowest score handles the pair."
  (let ((duet--backend-registry nil))
    (duet-register-backend (test-duet-backend--fake 'expensive 50))
    (duet-register-backend (test-duet-backend--fake 'cheap 5))
    (should (eq 'cheap (duet-backend-name (duet--select-backend nil nil))))))

(ert-deftest test-duet-backend-select-skips-nil-scorers ()
  "A backend whose scorer returns nil cannot handle the pair and is skipped."
  (let ((duet--backend-registry nil))
    (duet-register-backend (test-duet-backend--fake 'declines nil))
    (duet-register-backend (test-duet-backend--fake 'handles 30))
    (should (eq 'handles (duet-backend-name (duet--select-backend nil nil))))))

(ert-deftest test-duet-backend-select-none-handles-returns-nil ()
  "When no backend scores the pair, selection returns nil."
  (let ((duet--backend-registry nil))
    (duet-register-backend (test-duet-backend--fake 'a nil))
    (duet-register-backend (test-duet-backend--fake 'b nil))
    (should (null (duet--select-backend nil nil)))))

(ert-deftest test-duet-backend-select-tie-breaks-by-recency ()
  "Equal scores resolve to the most recently registered backend."
  (let ((duet--backend-registry nil))
    (duet-register-backend (test-duet-backend--fake 'older 10))
    (duet-register-backend (test-duet-backend--fake 'newer 10))
    (should (eq 'newer (duet-backend-name (duet--select-backend nil nil))))))

;;; Redaction

(ert-deftest test-duet-redact-replaces-pattern-matches ()
  "A pattern's secret is redacted while its group-1 label is kept."
  (let ((out (duet--redact "ssh password=hunter2 host=example"
                           '("\\(password=\\)[^ \n]+"))))
    (should (string-match-p "password=<redacted>" out))
    (should-not (string-match-p "hunter2" out))
    (should (string-match-p "host=example" out))))

(ert-deftest test-duet-redact-applies-every-pattern ()
  "Multiple patterns each redact independently."
  (let ((out (duet--redact "token=abc secret=def"
                           '("\\(token=\\)[^ \n]+" "\\(secret=\\)[^ \n]+"))))
    (should-not (string-match-p "abc" out))
    (should-not (string-match-p "def" out))))

(ert-deftest test-duet-redact-no-patterns-returns-input ()
  "With no patterns the text is returned unchanged."
  (should (equal "nothing here" (duet--redact "nothing here" nil))))

(ert-deftest test-duet-redact-none-returns-input ()
  "A backend declaring :none (no secret surface) redacts nothing."
  (should (equal "user@host:/path" (duet--redact "user@host:/path" :none))))

;;; Failure normalizer — minimal mapping and shape

(ert-deftest test-duet-normalize-failure-shape-has-all-keys ()
  "A normalized failure carries class, cause, evidence, safety, next-actions."
  (let ((n (duet--normalize-failure
            (test-duet-backend--fake 'x 1)
            '(:exit 1 :stderr "boom"))))
    (should (plist-member n :class))
    (should (plist-member n :cause))
    (should (plist-member n :evidence))
    (should (plist-member n :safety))
    (should (plist-member n :next-actions))))

(ert-deftest test-duet-normalize-failure-launch-error ()
  "A launch error classifies as launch-failure."
  (let ((n (duet--normalize-failure
            (test-duet-backend--fake 'x 1)
            '(:launch-error "No such file or directory"))))
    (should (eq 'launch-failure (plist-get n :class)))))

(ert-deftest test-duet-normalize-failure-unknown-nonzero-exit ()
  "An unrecognized nonzero exit falls back to backend-unknown-failure."
  (let ((n (duet--normalize-failure
            (test-duet-backend--fake 'x 1)
            '(:exit 42 :stderr "weird"))))
    (should (eq 'backend-unknown-failure (plist-get n :class)))))

;;; Failure-pattern mechanism

(ert-deftest test-duet-failure-patterns-match-by-stderr-regexp ()
  "A pattern whose :match regexp hits stderr supplies its class and cause."
  (let* ((normalizer
          (duet-define-cli-failure-patterns
           '((:match "Permission denied" :class permission-denied
                     :cause "The destination rejected the write."
                     :next-actions (fix-permissions)
                     :safety "Source unchanged."))))
         (n (funcall normalizer '(:exit 23 :stderr "rsync: Permission denied (13)"))))
    (should (eq 'permission-denied (plist-get n :class)))
    (should (equal '(fix-permissions) (plist-get n :next-actions)))))

(ert-deftest test-duet-failure-patterns-fall-back-to-minimal ()
  "When no pattern matches, the normalizer falls back to the minimal mapping."
  (let* ((normalizer
          (duet-define-cli-failure-patterns
           '((:match "Permission denied" :class permission-denied
                     :cause "..." :next-actions (fix-permissions)))))
         (n (funcall normalizer '(:exit 99 :stderr "totally different"))))
    (should (eq 'backend-unknown-failure (plist-get n :class)))))

(ert-deftest test-duet-normalize-failure-uses-backend-normalizer ()
  "A backend carrying a normalizer has it consulted before the minimal mapping."
  (let* ((normalizer
          (duet-define-cli-failure-patterns
           '((:match "rate" :class rate-limited :cause "Throttled."
                     :next-actions (retry-later)))))
         (backend (test-duet-backend--fake 'cloud 1 :normalizer normalizer))
         (n (duet--normalize-failure backend '(:exit 1 :stderr "API rate exceeded"))))
    (should (eq 'rate-limited (plist-get n :class)))))

(ert-deftest test-duet-normalize-failure-minimal-branches ()
  "The minimal normalizer maps each generic failure context to its class."
  (let ((b (test-duet-backend--fake 'x 1)))
    (should (eq 'missing-executable
               (plist-get (duet--normalize-failure b '(:executable-missing t)) :class)))
    (should (eq 'stalled
               (plist-get (duet--normalize-failure b '(:timeout t)) :class)))
    (should (eq 'cancelled
               (plist-get (duet--normalize-failure b '(:signal 9)) :class)))
    (should (eq 'backend-unknown-failure
               (plist-get (duet--normalize-failure b '(:stderr "no exit code")) :class)))))

(ert-deftest test-duet-failure-patterns-predicate-match ()
  "A :match predicate (not a regexp) is called with the whole context."
  (let* ((norm (duet-define-cli-failure-patterns
                (list (list :match (lambda (ctx) (eq 99 (plist-get ctx :exit)))
                            :class 'special :cause "x" :next-actions '(retry)))))
         (n (funcall norm '(:exit 99 :stderr ""))))
    (should (eq 'special (plist-get n :class)))))

(ert-deftest test-duet-redact-whole-match-without-group ()
  "A pattern with no capture group redacts the whole match."
  (should (equal "<redacted>" (duet--redact "secretvalue" '("secret[a-z]+")))))

(ert-deftest test-duet-backend-check-capability-flags-undeclared ()
  "A capability asserted but absent from `capabilities' is flagged."
  (let ((b (test-duet-backend--fake
            'cap 10 :cleanup :none
            :normalizer (duet-define-cli-failure-patterns nil))))
    (should (cl-some (lambda (s) (string-match-p "resume" s))
                     (duet-backend-check-capability b :resume)))))

(ert-deftest test-duet-backend-check-capability-passes-declared ()
  "A declared capability on an otherwise-publishable backend passes."
  (let ((b (test-duet-backend--fake
            'cap 10 :cleanup :none :capabilities '(:resume t)
            :normalizer (duet-define-cli-failure-patterns nil))))
    (should (null (duet-backend-check-capability b :resume)))))

;;; Contract checks — tiered

(ert-deftest test-duet-backend-check-minimum-passes-clean-backend ()
  "A well-formed fake backend reports no minimum-tier violations."
  (should (null (duet-backend-check-minimum (test-duet-backend--fake 'good 10)))))

(ert-deftest test-duet-backend-check-minimum-flags-missing-redaction ()
  "A backend that declares no redaction metadata fails the minimum tier."
  (let ((b (test-duet-backend--fake 'noredact 10 :redaction nil)))
    (should (duet-backend-check-minimum b))))

(ert-deftest test-duet-backend-check-minimum-accepts-none-redaction ()
  "A backend with no secret surface declares :none and passes the minimum tier."
  (let ((b (test-duet-backend--fake 'localonly 10 :redaction :none)))
    (should (null (duet-backend-check-minimum b)))))

(ert-deftest test-duet-backend-check-minimum-flags-shell-string-command ()
  "A command builder returning a shell string instead of an argv list fails."
  (let ((b (test-duet-backend--fake 'shellish 10
                                    :command (lambda (_s _d _o)
                                               (list :shell-command "rm -rf /")))))
    (should (duet-backend-check-minimum b))))

(ert-deftest test-duet-backend-check-minimum-flags-nil-command-spec ()
  "A command builder returning nil fails the minimum tier (nil is a list)."
  (let ((b (test-duet-backend--fake 'nilcmd 10 :command (lambda (_s _d _o) nil))))
    (should (duet-backend-check-minimum b))))

(ert-deftest test-duet-backend-check-minimum-flags-empty-argv-cli ()
  "A CLI backend with nil argv and no declared in-process mode fails."
  (let ((b (test-duet-backend--fake
            'noargv 10
            :command (lambda (_s _d _o) (list :argv nil :default-directory "/")))))
    (should (duet-backend-check-minimum b))))

(ert-deftest test-duet-backend-check-minimum-accepts-in-process-spec ()
  "A backend declaring an in-process mode (:tramp) passes with a nil argv."
  (let ((b (test-duet-backend--fake
            'inproc 10
            :command (lambda (_s _d _o) (list :argv nil :tramp t)))))
    (should (null (duet-backend-check-minimum b)))))

(ert-deftest test-duet-backend-check-minimum-flags-non-string-argv ()
  "An argv carrying non-string elements is not a runnable CLI command."
  (let ((b (test-duet-backend--fake
            'bad 10
            :command (lambda (_s _d _o) (list :argv '("rsync" 42))))))
    (should (duet-backend-check-minimum b))))

(ert-deftest test-duet-backend-check-publishable-flags-missing-cleanup ()
  "The publishable tier additionally requires declared cleanup semantics."
  (let ((b (test-duet-backend--fake 'pub 10
                                    :normalizer (duet-define-cli-failure-patterns nil))))
    ;; cleanup slot is unset, so publishable must flag it even though minimum passes.
    (should (null (duet-backend-check-minimum b)))
    (should (duet-backend-check-publishable b))))

(ert-deftest test-duet-backend-check-publishable-includes-minimum ()
  "Publishable problems are a superset of the minimum-tier problems."
  (let* ((b (test-duet-backend--fake 'broken 10 :redaction nil))
         (min (duet-backend-check-minimum b))
         (pub (duet-backend-check-publishable b)))
    (should min)
    (should (cl-every (lambda (p) (member p pub)) min))))

(provide 'test-duet-backend)
;;; test-duet-backend.el ends here