;;; jabber-test-receipts.el --- Tests for jabber-receipts  -*- lexical-binding: t; -*-

;;; Commentary:

;; XEP-0184 Delivery Receipts and XEP-0333 Chat Markers.

;;; Code:

(require 'ert)
(require 'jabber-receipts)
(require 'jabber-db)

;;; Group 1: Send hook

(ert-deftest jabber-test-receipts-send-hook-adds-elements ()
  "Send hook appends request and markable elements."
  (let ((result (jabber-receipts--send-hook "hello" "msg-001")))
    (should (assq 'request result))
    (should (assq 'markable result))))

(ert-deftest jabber-test-receipts-send-hook-correct-xmlns ()
  "Send hook elements have correct xmlns attributes."
  (let* ((result (jabber-receipts--send-hook "test" "id-1"))
         (request (assq 'request result))
         (markable (assq 'markable result)))
    (should (string= "urn:xmpp:receipts"
                      (cdr (assq 'xmlns (cadr request)))))
    (should (string= "urn:xmpp:chat-markers:0"
                      (cdr (assq 'xmlns (cadr markable)))))))

;;; Group 2: Incoming receipt handling

(ert-deftest jabber-test-receipts-handle-xep0184-received ()
  "Incoming XEP-0184 received stanza updates delivered_at in DB."
  (let ((updated-id nil)
        (updated-col nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (_acct _peer id col _ts)
                 (setq updated-id id updated-col col)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (jabber-receipts--handle-message
       'fake-jc
       '(message ((from . "them@example.com") (type . "chat"))
                 (received ((xmlns . "urn:xmpp:receipts")
                            (id . "msg-001"))))))
    (should (equal updated-id "msg-001"))
    (should (equal updated-col "delivered_at"))))

(ert-deftest jabber-test-receipts-handle-xep0333-displayed ()
  "Incoming XEP-0333 displayed stanza updates displayed_at in DB."
  (let ((updated-id nil)
        (updated-col nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (_acct _peer id col _ts)
                 (setq updated-id id updated-col col)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (jabber-receipts--handle-message
       'fake-jc
       '(message ((from . "them@example.com") (type . "chat"))
                 (displayed ((xmlns . "urn:xmpp:chat-markers:0")
                             (id . "msg-002"))))))
    (should (equal updated-id "msg-002"))
    (should (equal updated-col "displayed_at"))))

(ert-deftest jabber-test-receipts-handle-ignores-non-receipt ()
  "Messages without receipt elements are ignored."
  (let ((updated nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (&rest _args) (setq updated t)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com")))
      (jabber-receipts--handle-message
       'fake-jc
       '(message ((from . "them@example.com") (type . "chat"))
                 (body nil "hello"))))
    (should-not updated)))

;;; Group 3: Sending receipts back

(ert-deftest jabber-test-receipts-send-received-on-request ()
  "Incoming message with <request/> triggers <received/> response."
  (let ((sent-sexp nil))
    (cl-letf (((symbol-function 'jabber-send-sexp-if-connected)
               (lambda (_jc sexp) (setq sent-sexp sexp)))
              ((symbol-function 'jabber-db-update-receipt) #'ignore)
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (let ((jabber-chat-send-receipts t))
        (jabber-receipts--handle-message
         'fake-jc
         '(message ((from . "them@example.com") (id . "msg-100") (type . "chat"))
                   (body nil "hello")
                   (request ((xmlns . "urn:xmpp:receipts")))))))
    (should sent-sexp)
    ;; Verify it's a <received/> stanza
    (should (eq 'message (car sent-sexp)))
    (let ((children (cddr sent-sexp)))
      (should (assq 'received children)))))

(ert-deftest jabber-test-receipts-no-received-when-disabled ()
  "No <received/> sent when jabber-chat-send-receipts is nil."
  (let ((sent-sexp nil))
    (cl-letf (((symbol-function 'jabber-send-sexp-if-connected)
               (lambda (_jc sexp) (setq sent-sexp sexp)))
              ((symbol-function 'jabber-db-update-receipt) #'ignore)
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (let ((jabber-chat-send-receipts nil))
        (jabber-receipts--handle-message
         'fake-jc
         '(message ((from . "them@example.com") (id . "msg-100") (type . "chat"))
                   (body nil "hello")
                   (request ((xmlns . "urn:xmpp:receipts")))))))
    (should-not sent-sexp)))

(ert-deftest jabber-test-receipts-no-received-without-request ()
  "No <received/> sent for messages without <request/> element."
  (let ((sent-sexp nil))
    (cl-letf (((symbol-function 'jabber-send-sexp-if-connected)
               (lambda (_jc sexp) (setq sent-sexp sexp)))
              ((symbol-function 'jabber-db-update-receipt) #'ignore)
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (let ((jabber-chat-send-receipts t))
        (jabber-receipts--handle-message
         'fake-jc
         '(message ((from . "them@example.com") (id . "msg-100") (type . "chat"))
                   (body nil "hello")))))
    (should-not sent-sexp)))

(ert-deftest jabber-test-receipts-no-received-for-markable-only ()
  "No <received/> sent for messages with only <markable/> but no <request/>."
  (let ((sent-sexp nil))
    (cl-letf (((symbol-function 'jabber-send-sexp-if-connected)
               (lambda (_jc sexp) (setq sent-sexp sexp)))
              ((symbol-function 'jabber-db-update-receipt) #'ignore)
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (let ((jabber-chat-send-receipts t))
        (jabber-receipts--handle-message
         'fake-jc
         `(message ((from . "them@example.com") (id . "msg-100") (type . "chat"))
                   (body nil "hello")
                   (markable ((xmlns . ,jabber-chat-markers-xmlns)))))))
    (should-not sent-sexp)))

(ert-deftest jabber-test-receipts-markable-sets-pending-id ()
  "Incoming markable message sets pending displayed ID."
  (cl-letf (((symbol-function 'jabber-db-update-receipt) #'ignore)
            ((symbol-function 'jabber-send-sexp-if-connected) #'ignore)
            ((symbol-function 'jabber-connection-bare-jid)
             (lambda (_j) "me@example.com"))
            ((symbol-function 'jabber-chat-get-buffer)
             (lambda (_from &optional _jc) (buffer-name))))
    (with-temp-buffer
      (setq-local jabber-receipts--pending-displayed-id nil)
      (let ((jabber-chat-send-receipts t))
        (jabber-receipts--handle-message
         'fake-jc
         `(message ((from . "them@example.com") (id . "msg-200") (type . "chat"))
                   (body nil "hello")
                   (markable ((xmlns . ,jabber-chat-markers-xmlns))))))
      (should (equal "msg-200" jabber-receipts--pending-displayed-id)))))

;;; Group 4: Header-line update

(ert-deftest jabber-test-receipts-header-line-delivered ()
  "Header-line shows delivered with correct face."
  (with-temp-buffer
    (setq-local jabber-chat-receipt-message "")
    (jabber-receipts--update-header-line "delivered_at" 1700000000)
    (should (string-match-p "delivered" jabber-chat-receipt-message))
    (should (eq 'jabber-chat-delivered
                (get-text-property 0 'face jabber-chat-receipt-message)))))

(ert-deftest jabber-test-receipts-header-line-seen ()
  "Header-line shows seen with correct face."
  (with-temp-buffer
    (setq-local jabber-chat-receipt-message "")
    (jabber-receipts--update-header-line "displayed_at" 1700000000)
    (should (string-match-p "seen" jabber-chat-receipt-message))
    (should (eq 'jabber-chat-seen
                (get-text-property 0 'face jabber-chat-receipt-message)))))

(ert-deftest jabber-test-receipts-header-line-no-downgrade ()
  "Header-line does not downgrade from seen to delivered."
  (with-temp-buffer
    (setq-local jabber-chat-receipt-message "")
    (jabber-receipts--update-header-line "displayed_at" 1700000000)
    (should (string-match-p "seen" jabber-chat-receipt-message))
    ;; Now try to downgrade to delivered
    (jabber-receipts--update-header-line "delivered_at" 1700000001)
    (should (string-match-p "seen" jabber-chat-receipt-message))
    (should-not (string-match-p "delivered" jabber-chat-receipt-message))))

;;; Group 5: EWOC cascade

(ert-deftest jabber-test-receipts-ewoc-cascade-promotes-delivered ()
  "Cascade promotes all prior :delivered nodes to :displayed."
  (with-temp-buffer
    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
           (m1 (list :local (list :id "m1" :status :delivered :timestamp (encode-time 0 0 10 1 1 2026))))
           (m2 (list :local (list :id "m2" :status :delivered :timestamp (encode-time 0 1 10 1 1 2026))))
           (m3 (list :local (list :id "m3" :status :displayed :timestamp (encode-time 0 2 10 1 1 2026))))
           (_n1 (ewoc-enter-last jabber-chat-ewoc m1))
           (_n2 (ewoc-enter-last jabber-chat-ewoc m2))
           (n3 (ewoc-enter-last jabber-chat-ewoc m3)))
      (let ((inhibit-read-only t))
        (jabber-receipts--cascade-displayed n3))
      (should (eq :displayed (plist-get (cadr m1) :status)))
      (should (eq :displayed (plist-get (cadr m2) :status))))))

(ert-deftest jabber-test-receipts-ewoc-cascade-stops-at-displayed ()
  "Cascade stops walking when it hits an already-displayed node."
  (with-temp-buffer
    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
           (m1 (list :local (list :id "m1" :status :delivered :timestamp (encode-time 0 0 10 1 1 2026))))
           (m2 (list :local (list :id "m2" :status :displayed :timestamp (encode-time 0 1 10 1 1 2026))))
           (m3 (list :local (list :id "m3" :status :delivered :timestamp (encode-time 0 2 10 1 1 2026))))
           (m4 (list :local (list :id "m4" :status :displayed :timestamp (encode-time 0 3 10 1 1 2026))))
           (_n1 (ewoc-enter-last jabber-chat-ewoc m1))
           (_n2 (ewoc-enter-last jabber-chat-ewoc m2))
           (_n3 (ewoc-enter-last jabber-chat-ewoc m3))
           (n4 (ewoc-enter-last jabber-chat-ewoc m4)))
      (let ((inhibit-read-only t))
        (jabber-receipts--cascade-displayed n4))
      ;; m3 promoted
      (should (eq :displayed (plist-get (cadr m3) :status)))
      ;; m2 was already displayed, stops there
      (should (eq :displayed (plist-get (cadr m2) :status)))
      ;; m1 NOT promoted (before the already-displayed m2)
      (should (eq :delivered (plist-get (cadr m1) :status))))))

(ert-deftest jabber-test-receipts-ewoc-cascade-skips-foreign ()
  "Cascade skips :foreign nodes and only promotes :local ones."
  (with-temp-buffer
    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
           (m1 (list :local (list :id "m1" :status :delivered :timestamp (encode-time 0 0 10 1 1 2026))))
           (m2 (list :foreign (list :id "m2" :timestamp (encode-time 0 1 10 1 1 2026))))
           (m3 (list :local (list :id "m3" :status :displayed :timestamp (encode-time 0 2 10 1 1 2026))))
           (_n1 (ewoc-enter-last jabber-chat-ewoc m1))
           (_n2 (ewoc-enter-last jabber-chat-ewoc m2))
           (n3 (ewoc-enter-last jabber-chat-ewoc m3)))
      (let ((inhibit-read-only t))
        (jabber-receipts--cascade-displayed n3))
      (should (eq :displayed (plist-get (cadr m1) :status)))
      ;; foreign node untouched
      (should-not (plist-get (cadr m2) :status)))))

(ert-deftest jabber-test-receipts-ewoc-cascade-skips-sent ()
  "Cascade does not promote :sent nodes (not yet delivered)."
  (with-temp-buffer
    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
           (m1 (list :local (list :id "m1" :status :sent :timestamp (encode-time 0 0 10 1 1 2026))))
           (m2 (list :local (list :id "m2" :status :delivered :timestamp (encode-time 0 1 10 1 1 2026))))
           (m3 (list :local (list :id "m3" :status :displayed :timestamp (encode-time 0 2 10 1 1 2026))))
           (_n1 (ewoc-enter-last jabber-chat-ewoc m1))
           (_n2 (ewoc-enter-last jabber-chat-ewoc m2))
           (n3 (ewoc-enter-last jabber-chat-ewoc m3)))
      (let ((inhibit-read-only t))
        (jabber-receipts--cascade-displayed n3))
      (should (eq :displayed (plist-get (cadr m2) :status)))
      ;; :sent stays :sent
      (should (eq :sent (plist-get (cadr m1) :status))))))

;;; Group 6: DB cascade

(ert-deftest jabber-test-receipts-db-cascade-marks-all-delivered ()
  "DB cascade marks all delivered outgoing messages as displayed."
  (let* ((dir (make-temp-file "jabber-receipts-test" t))
         (jabber-db-path (expand-file-name "test.sqlite" dir))
         (jabber-db--connection nil)
         (jabber-backlog-days 3.0)
         (jabber-backlog-number 10))
    (unwind-protect
        (progn
          (jabber-db-ensure-open)
          ;; Store 3 outgoing messages
          (jabber-db-store-message "me@example.com" "them@example.com"
                                  "out" "chat" "msg1" 1000
                                  nil "id-1" )
          (jabber-db-store-message "me@example.com" "them@example.com"
                                  "out" "chat" "msg2" 1001
                                  nil "id-2" )
          (jabber-db-store-message "me@example.com" "them@example.com"
                                  "out" "chat" "msg3" 1002
                                  nil "id-3" )
          ;; Mark all as delivered
          (jabber-db-update-receipt "me@example.com" "them@example.com"
                                    "id-1" "delivered_at" 1010)
          (jabber-db-update-receipt "me@example.com" "them@example.com"
                                    "id-2" "delivered_at" 1011)
          (jabber-db-update-receipt "me@example.com" "them@example.com"
                                    "id-3" "delivered_at" 1012)
          ;; Cascade displayed from msg3
          (jabber-db-cascade-displayed "me@example.com" "them@example.com"
                                       2000 1002)
          ;; All 3 should have displayed_at
          (let ((rows (sqlite-select jabber-db--connection
                                     "SELECT stanza_id, displayed_at FROM message ORDER BY timestamp")))
            (should (= 3 (length rows)))
            (should (= 2000 (cadr (nth 0 rows))))
            (should (= 2000 (cadr (nth 1 rows))))
            (should (= 2000 (cadr (nth 2 rows))))))
      (jabber-db-close)
      (when (file-directory-p dir)
        (delete-directory dir t)))))

(ert-deftest jabber-test-receipts-db-cascade-skips-undelivered ()
  "DB cascade skips messages without delivered_at."
  (let* ((dir (make-temp-file "jabber-receipts-test" t))
         (jabber-db-path (expand-file-name "test.sqlite" dir))
         (jabber-db--connection nil)
         (jabber-backlog-days 3.0)
         (jabber-backlog-number 10))
    (unwind-protect
        (progn
          (jabber-db-ensure-open)
          (jabber-db-store-message "me@example.com" "them@example.com"
                                  "out" "chat" "msg1" 1000
                                  nil "id-1" )
          (jabber-db-store-message "me@example.com" "them@example.com"
                                  "out" "chat" "msg2" 1001
                                  nil "id-2" )
          (jabber-db-store-message "me@example.com" "them@example.com"
                                  "out" "chat" "msg3" 1002
                                  nil "id-3" )
          ;; Only mark msg1 and msg3 as delivered (msg2 undelivered)
          (jabber-db-update-receipt "me@example.com" "them@example.com"
                                    "id-1" "delivered_at" 1010)
          (jabber-db-update-receipt "me@example.com" "them@example.com"
                                    "id-3" "delivered_at" 1012)
          ;; Cascade displayed from msg3
          (jabber-db-cascade-displayed "me@example.com" "them@example.com"
                                       2000 1002)
          (let ((rows (sqlite-select jabber-db--connection
                                     "SELECT stanza_id, displayed_at FROM message ORDER BY timestamp")))
            ;; msg1 displayed (delivered + before ref-ts)
            (should (= 2000 (cadr (nth 0 rows))))
            ;; msg2 NOT displayed (not delivered)
            (should (null (cadr (nth 1 rows))))
            ;; msg3 displayed (delivered + at ref-ts)
            (should (= 2000 (cadr (nth 2 rows))))))
      (jabber-db-close)
      (when (file-directory-p dir)
        (delete-directory dir t)))))

;;; Group 7: MUC guards and edge cases

(ert-deftest jabber-test-receipts-groupchat-skips-receipt-processing ()
  "Groupchat messages do not trigger receipt updates."
  (let ((updated nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (&rest _args) (setq updated t)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com")))
      (jabber-receipts--handle-message
       'fake-jc
       '(message ((from . "room@conference.example.com/nick")
                  (type . "groupchat"))
                 (received ((xmlns . "urn:xmpp:receipts")
                            (id . "msg-001"))))))
    (should-not updated)))

(ert-deftest jabber-test-receipts-nil-type-does-not-crash ()
  "Messages without type attribute do not crash."
  (let ((updated-id nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (_acct _peer id _col _ts)
                 (setq updated-id id)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (jabber-receipts--handle-message
       'fake-jc
       '(message ((from . "them@example.com"))
                 (received ((xmlns . "urn:xmpp:receipts")
                            (id . "msg-nil-type"))))))
    (should (equal updated-id "msg-nil-type"))))

(ert-deftest jabber-test-receipts-xep0333-received-marker ()
  "XEP-0333 <received/> marker updates delivered_at."
  (let ((updated-id nil)
        (updated-col nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (_acct _peer id col _ts)
                 (setq updated-id id updated-col col)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (jabber-receipts--handle-message
       'fake-jc
       '(message ((from . "them@example.com") (type . "chat"))
                 (received ((xmlns . "urn:xmpp:chat-markers:0")
                            (id . "msg-333"))))))
    (should (equal updated-id "msg-333"))
    (should (equal updated-col "delivered_at"))))

(ert-deftest jabber-test-receipts-send-hook-muc-no-request ()
  "Send hook in MUC groupchat adds markable but no request."
  (with-temp-buffer
    (setq-local jabber-group "room@conference.example.com")
    (let ((result (jabber-receipts--send-hook "hello" "msg-001")))
      (should (assq 'markable result))
      (should-not (assq 'request result)))))

;;; Group 8: XEP-0280 Message Carbons

(defun jabber-receipts-tests--received-carbon (inner)
  "Wrap INNER message in a valid XEP-0280 <received> carbon envelope."
  `(message ((from . "me@example.com")
             (to . "me@example.com/emacs")
             (type . "chat"))
            (received ((xmlns . "urn:xmpp:carbons:2"))
                      (forwarded ((xmlns . "urn:xmpp:forward:0"))
                                 ,inner))))

(defun jabber-receipts-tests--sent-carbon (inner)
  "Wrap INNER message in a valid XEP-0280 <sent> carbon envelope."
  `(message ((from . "me@example.com")
             (to . "me@example.com/emacs")
             (type . "chat"))
            (sent ((xmlns . "urn:xmpp:carbons:2"))
                  (forwarded ((xmlns . "urn:xmpp:forward:0"))
                             ,inner))))

(ert-deftest jabber-test-receipts-carbon-received-queues-displayed ()
  "Markable message received as a <received> carbon queues pending-displayed-id."
  (cl-letf (((symbol-function 'jabber-db-update-receipt) #'ignore)
            ((symbol-function 'jabber-send-sexp-if-connected) #'ignore)
            ((symbol-function 'jabber-connection-bare-jid)
             (lambda (_j) "me@example.com"))
            ((symbol-function 'jabber-chat-get-buffer)
             (lambda (_from &optional _jc) (buffer-name))))
    (with-temp-buffer
      (setq-local jabber-receipts--pending-displayed-id nil)
      (let ((jabber-chat-send-receipts t)
            (inner `(message ((from . "them@example.com")
                              (to . "me@example.com")
                              (id . "inner-msg-42")
                              (type . "chat"))
                             (body nil "hi from phone")
                             (markable ((xmlns . ,jabber-chat-markers-xmlns))))))
        (jabber-receipts--handle-message
         'fake-jc
         (jabber-receipts-tests--received-carbon inner)))
      (should (equal "inner-msg-42" jabber-receipts--pending-displayed-id)))))

(ert-deftest jabber-test-receipts-carbon-received-suppresses-xep0184 ()
  "Carbon-received messages with <request/> do NOT send <received/> reply."
  (let ((sent-sexp nil))
    (cl-letf (((symbol-function 'jabber-send-sexp-if-connected)
               (lambda (_jc sexp) (setq sent-sexp sexp)))
              ((symbol-function 'jabber-db-update-receipt) #'ignore)
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (let ((jabber-chat-send-receipts t)
            (inner `(message ((from . "them@example.com")
                              (to . "me@example.com")
                              (id . "inner-msg-50")
                              (type . "chat"))
                             (body nil "hello")
                             (request ((xmlns . ,jabber-receipts-xmlns))))))
        (jabber-receipts--handle-message
         'fake-jc
         (jabber-receipts-tests--received-carbon inner))))
    (should-not sent-sexp)))

(ert-deftest jabber-test-receipts-carbon-sent-does-not-queue ()
  "Our own outgoing message received as a <sent> carbon does not queue a marker."
  (cl-letf (((symbol-function 'jabber-db-update-receipt) #'ignore)
            ((symbol-function 'jabber-send-sexp-if-connected) #'ignore)
            ((symbol-function 'jabber-connection-bare-jid)
             (lambda (_j) "me@example.com"))
            ((symbol-function 'jabber-chat-get-buffer)
             (lambda (_from &optional _jc) (buffer-name))))
    (with-temp-buffer
      (setq-local jabber-receipts--pending-displayed-id nil)
      (let ((jabber-chat-send-receipts t)
            (inner `(message ((from . "me@example.com")
                              (to . "them@example.com")
                              (id . "our-msg-99")
                              (type . "chat"))
                             (body nil "hi from phone")
                             (markable ((xmlns . ,jabber-chat-markers-xmlns))))))
        (jabber-receipts--handle-message
         'fake-jc
         (jabber-receipts-tests--sent-carbon inner)))
      (should-not jabber-receipts--pending-displayed-id))))

(ert-deftest jabber-test-receipts-carbon-forged-ignored ()
  "Carbon whose outer `from' does not match our bare JID is dropped (CVE-2017-5589)."
  (let ((sent-sexp nil)
        (updated nil))
    (cl-letf (((symbol-function 'jabber-send-sexp-if-connected)
               (lambda (_jc sexp) (setq sent-sexp sexp)))
              ((symbol-function 'jabber-db-update-receipt)
               (lambda (&rest _args) (setq updated t)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) (buffer-name))))
      (with-temp-buffer
        (setq-local jabber-receipts--pending-displayed-id nil)
        (let ((jabber-chat-send-receipts t)
              (inner `(message ((from . "them@example.com")
                                (to . "me@example.com")
                                (id . "forged-id")
                                (type . "chat"))
                               (body nil "evil")
                               (markable ((xmlns . ,jabber-chat-markers-xmlns))))))
          ;; Forged outer from: attacker@evil.com, not our bare JID.
          (jabber-receipts--handle-message
           'fake-jc
           `(message ((from . "attacker@evil.com")
                      (to . "me@example.com/emacs")
                      (type . "chat"))
                     (received ((xmlns . "urn:xmpp:carbons:2"))
                               (forwarded ((xmlns . "urn:xmpp:forward:0"))
                                          ,inner)))))
        (should-not jabber-receipts--pending-displayed-id)))
    (should-not sent-sexp)
    (should-not updated)))

(ert-deftest jabber-test-receipts-carbon-received-displayed-marker ()
  "Peer's <displayed/> delivered via a <received> carbon updates status."
  (let ((updated-id nil)
        (updated-col nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (_acct _peer id col _ts)
                 (setq updated-id id updated-col col)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (let ((inner `(message ((from . "them@example.com")
                              (to . "me@example.com")
                              (type . "chat"))
                             (displayed ((xmlns . ,jabber-chat-markers-xmlns)
                                         (id . "our-msg-33"))))))
        (jabber-receipts--handle-message
         'fake-jc
         (jabber-receipts-tests--received-carbon inner))))
    (should (equal updated-id "our-msg-33"))
    (should (equal updated-col "displayed_at"))))

(ert-deftest jabber-test-receipts-carbon-sent-skips-incoming-marker ()
  "Our own outgoing <displayed/> seen as <sent> carbon does NOT update status.
Cross-device read-sync is a deferred feature; for now we just don't corrupt
peer state by treating our own emitted markers as incoming ones."
  (let ((updated nil))
    (cl-letf (((symbol-function 'jabber-db-update-receipt)
               (lambda (&rest _args) (setq updated t)))
              ((symbol-function 'jabber-connection-bare-jid)
               (lambda (_j) "me@example.com"))
              ((symbol-function 'jabber-chat-get-buffer)
               (lambda (_from &optional _jc) "*test-chat*")))
      (let ((inner `(message ((from . "me@example.com")
                              (to . "them@example.com")
                              (type . "chat"))
                             (displayed ((xmlns . ,jabber-chat-markers-xmlns)
                                         (id . "peer-msg-77"))))))
        (jabber-receipts--handle-message
         'fake-jc
         (jabber-receipts-tests--sent-carbon inner))))
    (should-not updated)))

;;; Group 9: Status anti-downgrade

(ert-deftest jabber-test-receipts-status-upgrade-sent-to-delivered ()
  "Status upgrade from :sent to :delivered is allowed."
  (should (jabber-receipts--status-upgrades-p :sent :delivered)))

(ert-deftest jabber-test-receipts-status-upgrade-delivered-to-displayed ()
  "Status upgrade from :delivered to :displayed is allowed."
  (should (jabber-receipts--status-upgrades-p :delivered :displayed)))

(ert-deftest jabber-test-receipts-status-no-downgrade-displayed-to-delivered ()
  "Status downgrade from :displayed to :delivered is rejected."
  (should-not (jabber-receipts--status-upgrades-p :displayed :delivered)))

(ert-deftest jabber-test-receipts-status-no-downgrade-same ()
  "Same status is not an upgrade."
  (should-not (jabber-receipts--status-upgrades-p :delivered :delivered)))

(ert-deftest jabber-test-receipts-update-status-no-downgrade-ewoc ()
  "A :delivered update does not overwrite :displayed in the EWOC."
  (with-temp-buffer
    (let* ((jabber-chat-ewoc (ewoc-create #'ignore))
           (msg (list :id "msg-99" :status :displayed
                      :timestamp (encode-time 0 0 10 1 1 2026)))
           (node (ewoc-enter-last jabber-chat-ewoc (list :local msg))))
      (cl-letf (((symbol-function 'jabber-db-update-receipt) #'ignore)
                ((symbol-function 'jabber-connection-bare-jid)
                 (lambda (_j) "me@example.com"))
                ((symbol-function 'jabber-chat-get-buffer)
                 (lambda (_from &optional _jc) (buffer-name)))
                ((symbol-function 'jabber-chat-ewoc-find-by-id)
                 (lambda (_id) node))
                ((symbol-function 'jabber-chat-ewoc-invalidate) #'ignore))
        (jabber-receipts--update-status
         'fake-jc "them@example.com" "msg-99" "delivered_at"))
      (should (eq :displayed (plist-get msg :status))))))

(provide 'jabber-test-receipts)

;;; jabber-test-receipts.el ends here
