Files
async-http-queue-el/async-http-queue-test.el
Andros Fenollosa 152908bf32 Add comprehensive ERT test suite
55 tests covering struct operations, status updates, queue
construction, parameter defaults, fetch behavior with mocked
url-retrieve, error handling, timeout, parser modes, concurrency
limits, result ordering and full integration flow.
2026-03-16 09:05:31 +01:00

887 lines
39 KiB
EmacsLisp

;;; async-http-queue-test.el --- Tests for async-http-queue -*- lexical-binding: t -*-
;; Copyright (C) 2025 Andros Fenollosa
;; Author: Andros Fenollosa <hi@andros.dev>
;; 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.
;;; Commentary:
;; Test suite for async-http-queue. Uses ERT with mocked `url-retrieve'
;; to test all functionality without network dependencies.
;;
;; To run all tests from the shell:
;; Emacs -Q --batch -L . -l async-http-queue-test.el -f ert-run-tests-batch-and-exit
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'async-http-queue)
;;;; Test helpers
(defvar async-http-queue-test--timeout 5
"Maximum seconds to wait for async operations in tests.")
(defun async-http-queue-test--wait (predicate &optional timeout)
"Wait until PREDICATE return non-nil or TIMEOUT seconds elapse.
TIMEOUT defaults to `async-http-queue-test--timeout'."
(let ((deadline (+ (float-time) (or timeout async-http-queue-test--timeout))))
(while (and (not (funcall predicate))
(< (float-time) deadline))
(accept-process-output nil 0.05))
(funcall predicate)))
(defun async-http-queue-test--make-http-response (status-code body &optional content-type)
"Build a fake HTTP response string with STATUS-CODE and BODY.
CONTENT-TYPE defaults to application/json."
(let ((ct (or content-type "application/json")))
(format "HTTP/1.1 %d OK\r\nContent-Type: %s\r\n\r\n%s"
status-code ct body)))
(defun async-http-queue-test--mock-url-retrieve (responses)
"Return a function that replace `url-retrieve'.
RESPONSES is an alist of (URL . RESPONSE-STRING) or (URL . :timeout)
or (URL . :error). When a URL is fetched, the corresponding response
is delivered asynchronously via a timer."
(lambda (url callback &optional _cbargs _silent)
(let* ((entry (assoc url responses))
(response (cdr entry))
(buf (generate-new-buffer (format " *test-url-%s*" url))))
(with-current-buffer buf
(cond
((eq response :timeout)
;; Do not call the callback, simulating a hang
nil)
((eq response :error)
;; Call callback with error status after a short delay
(run-at-time 0.01 nil
(lambda ()
(with-current-buffer buf
(funcall callback (list :error '(error connection-refused)))))))
(t
;; Insert the response and call the callback after a short delay
(insert (or response
(async-http-queue-test--make-http-response 200 "{}")))
(run-at-time 0.01 nil
(lambda ()
(with-current-buffer buf
(funcall callback nil)))))))
buf)))
(defmacro async-http-queue-test--with-mock (responses &rest body)
"Execute BODY with `url-retrieve' replaced by a mock using RESPONSES."
(declare (indent 1))
`(cl-letf (((symbol-function 'url-retrieve)
(async-http-queue-test--mock-url-retrieve ,responses)))
,@body))
(defun async-http-queue-test--make-state (&optional urls)
"Create a test state with URLS (defaults to 3 test URLs)."
(let ((url-list (or urls '("http://test/1" "http://test/2" "http://test/3"))))
(async-http-queue--state-create
:queue (mapcar (lambda (url)
`((url . ,url)
(status . pending)
(data . nil)))
url-list)
:active-workers 0
:max-concurrent 5
:timeout 10
:parser #'json-parse-buffer
:completion-callback nil
:error-callback nil)))
;;;; Struct tests
(ert-deftest async-http-queue-test-state-create ()
"Verify struct creation with all fields."
(let ((state (async-http-queue--state-create
:queue '(((url . "http://a") (status . pending) (data . nil)))
:active-workers 0
:max-concurrent 3
:timeout 15
:parser #'json-parse-buffer
:completion-callback #'ignore
:error-callback #'ignore)))
(should (= (async-http-queue--state-active-workers state) 0))
(should (= (async-http-queue--state-max-concurrent state) 3))
(should (= (async-http-queue--state-timeout state) 15))
(should (eq (async-http-queue--state-parser state) #'json-parse-buffer))
(should (eq (async-http-queue--state-completion-callback state) #'ignore))
(should (eq (async-http-queue--state-error-callback state) #'ignore))
(should (= (length (async-http-queue--state-queue state)) 1))))
(ert-deftest async-http-queue-test-state-no-copier ()
"Verify copier is suppressed."
(should-not (fboundp 'copy-async-http-queue--state)))
;;;; Update status tests
(ert-deftest async-http-queue-test-update-status ()
"Verify status update for a matching URL."
(let ((state (async-http-queue-test--make-state)))
(async-http-queue--update-status state "http://test/2" 'processing)
(let ((item (seq-find (lambda (i) (equal (alist-get 'url i) "http://test/2"))
(async-http-queue--state-queue state))))
(should (eq (alist-get 'status item) 'processing)))))
(ert-deftest async-http-queue-test-update-status-no-side-effects ()
"Verify status update does not affect other items."
(let ((state (async-http-queue-test--make-state)))
(async-http-queue--update-status state "http://test/2" 'processing)
(let ((item1 (seq-find (lambda (i) (equal (alist-get 'url i) "http://test/1"))
(async-http-queue--state-queue state)))
(item3 (seq-find (lambda (i) (equal (alist-get 'url i) "http://test/3"))
(async-http-queue--state-queue state))))
(should (eq (alist-get 'status item1) 'pending))
(should (eq (alist-get 'status item3) 'pending)))))
(ert-deftest async-http-queue-test-update-status-nonexistent-url ()
"Verify update with non-matching URL does not change anything."
(let* ((state (async-http-queue-test--make-state))
(queue-before (copy-tree (async-http-queue--state-queue state))))
(async-http-queue--update-status state "http://nonexistent" 'done)
(should (equal (mapcar (lambda (i) (alist-get 'status i))
(async-http-queue--state-queue state))
(mapcar (lambda (i) (alist-get 'status i))
queue-before)))))
;;;; Update data tests
(ert-deftest async-http-queue-test-update-data ()
"Verify data update for a matching URL."
(let ((state (async-http-queue-test--make-state))
(test-data '((title . "Hello"))))
(async-http-queue--update-data state "http://test/1" test-data)
(let ((item (seq-find (lambda (i) (equal (alist-get 'url i) "http://test/1"))
(async-http-queue--state-queue state))))
(should (equal (alist-get 'data item) test-data)))))
(ert-deftest async-http-queue-test-update-data-no-side-effects ()
"Verify data update does not affect other items."
(let ((state (async-http-queue-test--make-state)))
(async-http-queue--update-data state "http://test/1" "some-data")
(let ((item2 (seq-find (lambda (i) (equal (alist-get 'url i) "http://test/2"))
(async-http-queue--state-queue state))))
(should (null (alist-get 'data item2))))))
;;;; Check completion tests
(ert-deftest async-http-queue-test-check-completion-all-done ()
"Verify completion fires when all items are done."
(let* ((result nil)
(state (async-http-queue-test--make-state '("http://a"))))
(setf (async-http-queue--state-completion-callback state)
(lambda (r) (setq result r)))
(async-http-queue--update-status state "http://a" 'done)
(async-http-queue--update-data state "http://a" '((key . "val")))
(async-http-queue--check-completion state)
(should (vectorp result))
(should (= (length result) 1))
(should (equal (aref result 0) '((key . "val"))))))
(ert-deftest async-http-queue-test-check-completion-not-ready ()
"Verify completion does not fire while items are pending."
(let* ((called nil)
(state (async-http-queue-test--make-state '("http://a" "http://b"))))
(setf (async-http-queue--state-completion-callback state)
(lambda (_) (setq called t)))
(async-http-queue--update-status state "http://a" 'done)
;; http://b is still pending
(async-http-queue--check-completion state)
(should-not called)))
(ert-deftest async-http-queue-test-check-completion-with-errors ()
"Verify completion fires with nil for failed items."
(let* ((result nil)
(state (async-http-queue-test--make-state '("http://a" "http://b"))))
(setf (async-http-queue--state-completion-callback state)
(lambda (r) (setq result r)))
(async-http-queue--update-status state "http://a" 'done)
(async-http-queue--update-data state "http://a" "ok")
(async-http-queue--update-status state "http://b" 'error)
(async-http-queue--check-completion state)
(should (vectorp result))
(should (equal (aref result 0) "ok"))
(should (null (aref result 1)))))
(ert-deftest async-http-queue-test-check-completion-nil-callback ()
"Verify no error when completion-callback is nil."
(let ((state (async-http-queue-test--make-state '("http://a"))))
(setf (async-http-queue--state-completion-callback state) nil)
(async-http-queue--update-status state "http://a" 'done)
(async-http-queue--check-completion state)))
(ert-deftest async-http-queue-test-result-order-preserved ()
"Verify results vector preserves original URL order."
(let* ((result nil)
(urls '("http://a" "http://b" "http://c"))
(state (async-http-queue-test--make-state urls)))
(setf (async-http-queue--state-completion-callback state)
(lambda (r) (setq result r)))
;; Complete in reverse order
(async-http-queue--update-status state "http://c" 'done)
(async-http-queue--update-data state "http://c" "third")
(async-http-queue--update-status state "http://a" 'done)
(async-http-queue--update-data state "http://a" "first")
(async-http-queue--update-status state "http://b" 'done)
(async-http-queue--update-data state "http://b" "second")
(async-http-queue--check-completion state)
(should (equal (aref result 0) "first"))
(should (equal (aref result 1) "second"))
(should (equal (aref result 2) "third"))))
;;;; Empty URL list tests
(ert-deftest async-http-queue-test-empty-urls-with-callback ()
"Verify empty URL list calls callback with empty vector."
(let ((result nil))
(async-http-queue nil :callback (lambda (r) (setq result r)))
(should (vectorp result))
(should (= (length result) 0))))
(ert-deftest async-http-queue-test-empty-urls-no-callback ()
"Verify empty URL list with no callback does not error."
(async-http-queue nil))
;;;; Message tests
(ert-deftest async-http-queue-test-message-singular ()
"Verify singular message for 1 URL."
(let ((messages nil))
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args)
(push (apply #'format fmt args) messages)))
((symbol-function 'url-retrieve)
(lambda (&rest _) (generate-new-buffer " *test*"))))
(async-http-queue '("http://test/1")
:callback #'ignore)
(should (member "Fetching 1 URL..." messages)))))
(ert-deftest async-http-queue-test-message-plural ()
"Verify plural message for multiple URLs."
(let ((messages nil))
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args)
(push (apply #'format fmt args) messages)))
((symbol-function 'url-retrieve)
(lambda (&rest _) (generate-new-buffer " *test*"))))
(async-http-queue '("http://test/1" "http://test/2")
:callback #'ignore)
(should (member "Fetching 2 URLs..." messages)))))
(ert-deftest async-http-queue-test-final-message-no-failures ()
"Verify final message when all succeed."
(let* ((messages nil)
(state (async-http-queue-test--make-state '("http://a"))))
(setf (async-http-queue--state-completion-callback state) #'ignore)
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args)
(push (apply #'format fmt args) messages))))
(async-http-queue--update-status state "http://a" 'done)
(async-http-queue--check-completion state)
(should (member "Loaded 1 URLs" messages)))))
(ert-deftest async-http-queue-test-final-message-with-failures ()
"Verify final message includes failure count."
(let* ((messages nil)
(state (async-http-queue-test--make-state '("http://a" "http://b"))))
(setf (async-http-queue--state-completion-callback state) #'ignore)
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args)
(push (apply #'format fmt args) messages))))
(async-http-queue--update-status state "http://a" 'done)
(async-http-queue--update-status state "http://b" 'error)
(async-http-queue--check-completion state)
(should (member "Loaded 1 URLs (1 failed)" messages)))))
(ert-deftest async-http-queue-test-progress-message-large-batch ()
"Verify progress messages appear for batches > 10."
(let* ((messages nil)
(urls (cl-loop for i from 1 to 12
collect (format "http://test/%d" i)))
(state (async-http-queue-test--make-state urls)))
(setf (async-http-queue--state-completion-callback state) #'ignore)
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args)
(push (apply #'format fmt args) messages))))
;; Complete only some items so in-progress > 0
(async-http-queue--update-status state "http://test/1" 'done)
(async-http-queue--check-completion state)
;; Should have a progress message
(should (cl-some (lambda (m) (string-match "Loading URLs\\.\\.\\." m))
messages)))))
(ert-deftest async-http-queue-test-no-progress-message-small-batch ()
"Verify no progress messages for batches <= 10."
(let* ((messages nil)
(state (async-http-queue-test--make-state '("http://a" "http://b"))))
(cl-letf (((symbol-function 'message)
(lambda (fmt &rest args)
(push (apply #'format fmt args) messages))))
(async-http-queue--update-status state "http://a" 'done)
(async-http-queue--check-completion state)
(should-not (cl-some (lambda (m) (string-match "Loading URLs" m))
messages)))))
;;;; Default parameter tests
(ert-deftest async-http-queue-test-default-max-concurrent ()
"Verify default max-concurrent is 5."
(let (captured-state)
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://test/1"))
(should (= (async-http-queue--state-max-concurrent captured-state) 5)))))
(ert-deftest async-http-queue-test-default-timeout ()
"Verify default timeout is 10."
(let (captured-state)
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://test/1"))
(should (= (async-http-queue--state-timeout captured-state) 10)))))
(ert-deftest async-http-queue-test-default-parser ()
"Verify default parser is `json-parse-buffer'."
(let (captured-state)
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://test/1"))
(should (eq (async-http-queue--state-parser captured-state)
#'json-parse-buffer)))))
(ert-deftest async-http-queue-test-custom-max-concurrent ()
"Verify custom max-concurrent is stored."
(let (captured-state)
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://test/1") :max-concurrent 3)
(should (= (async-http-queue--state-max-concurrent captured-state) 3)))))
(ert-deftest async-http-queue-test-custom-timeout ()
"Verify custom timeout is stored."
(let (captured-state)
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://test/1") :timeout 30)
(should (= (async-http-queue--state-timeout captured-state) 30)))))
(ert-deftest async-http-queue-test-nil-parser ()
"Verify nil parser is stored for raw text mode."
(let (captured-state)
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://test/1") :parser nil)
(should (null (async-http-queue--state-parser captured-state))))))
(ert-deftest async-http-queue-test-custom-parser ()
"Verify custom parser function is stored."
(let (captured-state
(my-parser (lambda () "custom")))
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://test/1") :parser my-parser)
(should (eq (async-http-queue--state-parser captured-state)
my-parser)))))
;;;; Queue construction tests
(ert-deftest async-http-queue-test-queue-construction ()
"Verify queue items have correct shape."
(let (captured-state)
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://a" "http://b"))
(let ((queue (async-http-queue--state-queue captured-state)))
(should (= (length queue) 2))
(dolist (item queue)
(should (assoc 'url item))
(should (assoc 'status item))
(should (assoc 'data item))
(should (eq (alist-get 'status item) 'pending))
(should (null (alist-get 'data item))))
(should (equal (alist-get 'url (nth 0 queue)) "http://a"))
(should (equal (alist-get 'url (nth 1 queue)) "http://b"))))))
(ert-deftest async-http-queue-test-callbacks-stored ()
"Verify callbacks are stored in state."
(let (captured-state
(my-cb (lambda (_) nil))
(my-err-cb (lambda (_) nil)))
(cl-letf (((symbol-function 'async-http-queue--process)
(lambda (state) (setq captured-state state)))
((symbol-function 'message) #'ignore))
(async-http-queue '("http://a")
:callback my-cb
:error-callback my-err-cb)
(should (eq (async-http-queue--state-completion-callback captured-state) my-cb))
(should (eq (async-http-queue--state-error-callback captured-state) my-err-cb)))))
;;;; Process-next-pending tests
(ert-deftest async-http-queue-test-process-next-respects-limit ()
"Verify no new worker starts when at capacity."
(let* ((fetch-called nil)
(state (async-http-queue-test--make-state '("http://a"))))
(setf (async-http-queue--state-active-workers state) 5)
(setf (async-http-queue--state-max-concurrent state) 5)
(cl-letf (((symbol-function 'async-http-queue--fetch-url)
(lambda (&rest _) (setq fetch-called t))))
(async-http-queue--process-next-pending state)
(should-not fetch-called))))
(ert-deftest async-http-queue-test-process-next-starts-worker ()
"Verify a worker starts when below capacity."
(let* ((fetch-called nil)
(fetched-url nil)
(state (async-http-queue-test--make-state '("http://a"))))
(setf (async-http-queue--state-active-workers state) 0)
(setf (async-http-queue--state-max-concurrent state) 5)
(cl-letf (((symbol-function 'async-http-queue--fetch-url)
(lambda (_state url _success _error)
(setq fetch-called t
fetched-url url))))
(async-http-queue--process-next-pending state)
(should fetch-called)
(should (equal fetched-url "http://a"))
(should (= (async-http-queue--state-active-workers state) 1)))))
(ert-deftest async-http-queue-test-process-next-skips-non-pending ()
"Verify already-processing items are skipped."
(let* ((fetch-called nil)
(state (async-http-queue-test--make-state '("http://a" "http://b"))))
(async-http-queue--update-status state "http://a" 'processing)
(setf (async-http-queue--state-active-workers state) 1)
(cl-letf (((symbol-function 'async-http-queue--fetch-url)
(lambda (_state url _success _error)
(setq fetch-called url))))
(async-http-queue--process-next-pending state)
(should (equal fetch-called "http://b")))))
(ert-deftest async-http-queue-test-process-next-noop-no-pending ()
"Verify noop when no pending items remain."
(let* ((fetch-called nil)
(state (async-http-queue-test--make-state '("http://a"))))
(async-http-queue--update-status state "http://a" 'done)
(setf (async-http-queue--state-active-workers state) 0)
(cl-letf (((symbol-function 'async-http-queue--fetch-url)
(lambda (&rest _) (setq fetch-called t))))
(async-http-queue--process-next-pending state)
(should-not fetch-called))))
;;;; Status transition tests
(ert-deftest async-http-queue-test-status-transitions ()
"Verify the valid status transitions: pending -> processing -> done/error."
(let ((state (async-http-queue-test--make-state '("http://a" "http://b"))))
;; Start as pending
(should (eq (alist-get 'status
(nth 0 (async-http-queue--state-queue state)))
'pending))
;; Transition to processing
(async-http-queue--update-status state "http://a" 'processing)
(should (eq (alist-get 'status
(seq-find (lambda (i) (equal (alist-get 'url i) "http://a"))
(async-http-queue--state-queue state)))
'processing))
;; Transition to done
(async-http-queue--update-status state "http://a" 'done)
(should (eq (alist-get 'status
(seq-find (lambda (i) (equal (alist-get 'url i) "http://a"))
(async-http-queue--state-queue state)))
'done))
;; Transition to error
(async-http-queue--update-status state "http://b" 'processing)
(async-http-queue--update-status state "http://b" 'error)
(should (eq (alist-get 'status
(seq-find (lambda (i) (equal (alist-get 'url i) "http://b"))
(async-http-queue--state-queue state)))
'error))))
;;;; Fetch URL tests (with mocked url-retrieve)
(ert-deftest async-http-queue-test-fetch-url-success-json ()
"Verify successful fetch with JSON parser."
(let* ((result nil)
(error-called nil)
(json-body "{\"title\": \"hello\"}")
(response (async-http-queue-test--make-http-response 200 json-body))
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10 :parser #'json-parse-buffer
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should-not error-called)
(should result)
(should (equal (gethash "title" result) "hello")))))
(ert-deftest async-http-queue-test-fetch-url-success-raw-text ()
"Verify successful fetch with nil parser returns raw text."
(let* ((result nil)
(error-called nil)
(body "Hello, world!")
(response (async-http-queue-test--make-http-response 200 body "text/plain"))
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10 :parser nil
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should-not error-called)
(should (equal result "Hello, world!")))))
(ert-deftest async-http-queue-test-fetch-url-success-custom-parser ()
"Verify successful fetch with custom parser."
(let* ((result nil)
(error-called nil)
(body "some data")
(response (async-http-queue-test--make-http-response 200 body "text/plain"))
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10
:parser (lambda ()
(upcase (buffer-substring-no-properties
(point) (point-max))))
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should-not error-called)
(should (equal result "SOME DATA")))))
(ert-deftest async-http-queue-test-fetch-url-http-error ()
"Verify non-2xx status code triggers error callback."
(let* ((result nil)
(error-called nil)
(response (async-http-queue-test--make-http-response 404 "Not Found"))
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10 :parser nil
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should error-called)
(should-not result))))
(ert-deftest async-http-queue-test-fetch-url-500-error ()
"Verify 500 status triggers error callback."
(let* ((result nil)
(error-called nil)
(response (async-http-queue-test--make-http-response 500 "Internal Server Error"))
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10 :parser nil
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should error-called)
(should-not result))))
(ert-deftest async-http-queue-test-fetch-url-connection-error ()
"Verify connection error triggers error callback."
(let* ((result nil)
(error-called nil)
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10 :parser nil
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
'(("http://test/1" . :error))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should error-called)
(should-not result))))
(ert-deftest async-http-queue-test-fetch-url-parser-error ()
"Verify parser failure triggers error callback."
(let* ((result nil)
(error-called nil)
(response (async-http-queue-test--make-http-response 200 "not valid json"))
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10 :parser #'json-parse-buffer
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should error-called)
(should-not result))))
(ert-deftest async-http-queue-test-fetch-url-invalid-response ()
"Verify invalid HTTP response (no status line) triggers error."
(let* ((result nil)
(error-called nil)
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 10 :parser nil
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
'(("http://test/1" . "garbage response with no http status"))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () (or result error-called))))
(should error-called)
(should-not result))))
(ert-deftest async-http-queue-test-fetch-url-timeout ()
"Verify timeout triggers error callback."
(let* ((result nil)
(error-called nil)
(state (async-http-queue--state-create
:queue nil :active-workers 0 :max-concurrent 5
:timeout 0.1 :parser nil
:completion-callback nil :error-callback nil)))
(async-http-queue-test--with-mock
'(("http://test/1" . :timeout))
(async-http-queue--fetch-url
state "http://test/1"
(lambda (data) (setq result data))
(lambda () (setq error-called t)))
(should (async-http-queue-test--wait (lambda () error-called) 3))
(should error-called)
(should-not result))))
;;;; Full integration tests (with mocked url-retrieve)
(ert-deftest async-http-queue-test-integration-single-url ()
"Verify full flow with a single URL."
(let* ((result nil)
(json-body "{\"id\": 1}")
(response (async-http-queue-test--make-http-response 200 json-body)))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue '("http://test/1")
:callback (lambda (r) (setq result r)))
(should (async-http-queue-test--wait (lambda () result)))
(should (vectorp result))
(should (= (length result) 1))
(should (= (gethash "id" (aref result 0)) 1)))))
(ert-deftest async-http-queue-test-integration-multiple-urls ()
"Verify full flow with multiple URLs."
(let* ((result nil)
(responses `(("http://test/1"
. ,(async-http-queue-test--make-http-response
200 "{\"id\": 1}"))
("http://test/2"
. ,(async-http-queue-test--make-http-response
200 "{\"id\": 2}"))
("http://test/3"
. ,(async-http-queue-test--make-http-response
200 "{\"id\": 3}")))))
(async-http-queue-test--with-mock responses
(async-http-queue '("http://test/1" "http://test/2" "http://test/3")
:callback (lambda (r) (setq result r)))
(should (async-http-queue-test--wait (lambda () result)))
(should (= (length result) 3))
(should (= (gethash "id" (aref result 0)) 1))
(should (= (gethash "id" (aref result 1)) 2))
(should (= (gethash "id" (aref result 2)) 3)))))
(ert-deftest async-http-queue-test-integration-mixed-success-failure ()
"Verify results contain nil for failed URLs."
(let* ((result nil)
(error-urls nil)
(responses `(("http://test/1"
. ,(async-http-queue-test--make-http-response
200 "{\"id\": 1}"))
("http://test/2"
. ,(async-http-queue-test--make-http-response
500 "error"))
("http://test/3"
. ,(async-http-queue-test--make-http-response
200 "{\"id\": 3}")))))
(async-http-queue-test--with-mock responses
(async-http-queue '("http://test/1" "http://test/2" "http://test/3")
:callback (lambda (r) (setq result r))
:error-callback (lambda (url) (push url error-urls)))
(should (async-http-queue-test--wait (lambda () result)))
(should (= (length result) 3))
(should (= (gethash "id" (aref result 0)) 1))
(should (null (aref result 1)))
(should (= (gethash "id" (aref result 2)) 3))
(should (member "http://test/2" error-urls)))))
(ert-deftest async-http-queue-test-integration-all-fail ()
"Verify all-failure scenario."
(let* ((result nil)
(error-urls nil)
(responses '(("http://test/1" . :error)
("http://test/2" . :error))))
(async-http-queue-test--with-mock responses
(async-http-queue '("http://test/1" "http://test/2")
:callback (lambda (r) (setq result r))
:error-callback (lambda (url) (push url error-urls)))
(should (async-http-queue-test--wait (lambda () result)))
(should (= (length result) 2))
(should (null (aref result 0)))
(should (null (aref result 1)))
(should (= (length error-urls) 2)))))
(ert-deftest async-http-queue-test-integration-raw-text ()
"Verify raw text mode with nil parser."
(let* ((result nil)
(body "Hello raw text")
(response (async-http-queue-test--make-http-response
200 body "text/plain")))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue '("http://test/1")
:parser nil
:callback (lambda (r) (setq result r)))
(should (async-http-queue-test--wait (lambda () result)))
(should (equal (aref result 0) "Hello raw text")))))
(ert-deftest async-http-queue-test-integration-custom-parser ()
"Verify custom parser in full flow."
(let* ((result nil)
(body "<root>data</root>")
(response (async-http-queue-test--make-http-response
200 body "text/xml")))
(async-http-queue-test--with-mock
`(("http://test/1" . ,response))
(async-http-queue '("http://test/1")
:parser (lambda ()
(upcase (buffer-substring-no-properties
(point) (point-max))))
:callback (lambda (r) (setq result r)))
(should (async-http-queue-test--wait (lambda () result)))
(should (equal (aref result 0) "<ROOT>DATA</ROOT>")))))
(ert-deftest async-http-queue-test-integration-custom-concurrency ()
"Verify custom concurrency is passed through."
(let* ((result nil)
(max-seen-workers 0)
(response (async-http-queue-test--make-http-response 200 "\"ok\"")))
(cl-letf (((symbol-function 'url-retrieve)
(lambda (_url callback &optional _cbargs _silent)
(let ((buf (generate-new-buffer " *test*")))
(with-current-buffer buf
(insert response))
;; Delay to allow concurrency measurement
(run-at-time 0.05 nil
(lambda ()
(with-current-buffer buf
(funcall callback nil))))
buf)))
((symbol-function 'async-http-queue--process-next-pending)
(let ((orig (symbol-function 'async-http-queue--process-next-pending)))
(lambda (state)
(funcall orig state)
(setq max-seen-workers
(max max-seen-workers
(async-http-queue--state-active-workers state)))))))
(async-http-queue '("http://test/1" "http://test/2" "http://test/3"
"http://test/4" "http://test/5")
:max-concurrent 2
:parser nil
:callback (lambda (r) (setq result r)))
(should (async-http-queue-test--wait (lambda () result)))
(should (<= max-seen-workers 2)))))
(ert-deftest async-http-queue-test-integration-error-callback-called ()
"Verify error-callback receives the failed URL."
(let* ((result nil)
(failed-url nil))
(async-http-queue-test--with-mock
'(("http://test/1" . :error))
(async-http-queue '("http://test/1")
:callback (lambda (r) (setq result r))
:error-callback (lambda (url) (setq failed-url url)))
(should (async-http-queue-test--wait (lambda () result)))
(should (equal failed-url "http://test/1")))))
(ert-deftest async-http-queue-test-integration-no-error-callback ()
"Verify no crash when error-callback is nil and errors occur."
(let ((result nil))
(async-http-queue-test--with-mock
'(("http://test/1" . :error))
(async-http-queue '("http://test/1")
:callback (lambda (r) (setq result r)))
(should (async-http-queue-test--wait (lambda () result)))
(should (null (aref result 0))))))
(ert-deftest async-http-queue-test-integration-timeout ()
"Verify timeout in full flow."
(let* ((result nil)
(error-urls nil))
(async-http-queue-test--with-mock
'(("http://test/1" . :timeout))
(async-http-queue '("http://test/1")
:timeout 0.2
:callback (lambda (r) (setq result r))
:error-callback (lambda (url) (push url error-urls)))
(should (async-http-queue-test--wait (lambda () result) 5))
(should (null (aref result 0)))
(should (member "http://test/1" error-urls)))))
(ert-deftest async-http-queue-test-integration-result-is-vector ()
"Verify callback receives a vector, not a list."
(let ((result nil))
(async-http-queue-test--with-mock
`(("http://test/1"
. ,(async-http-queue-test--make-http-response 200 "\"data\"")))
(async-http-queue '("http://test/1")
:callback (lambda (r) (setq result r)))
(should (async-http-queue-test--wait (lambda () result)))
(should (vectorp result)))))
;;;; Provide test feature
(ert-deftest async-http-queue-test-provide ()
"Verify the package provides the expected feature."
(should (featurep 'async-http-queue)))
(provide 'async-http-queue-test)
;;; async-http-queue-test.el ends here