summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:42:28 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:42:28 -0300
commitae9ad8a4c61b793f6859c84dac80b1c1b04b7b68 (patch)
tree62271e7672dfc58f59dda2ea4dc539c89a1ea5ca /tests
downloadlisp-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests')
-rw-r--r--tests/package.lisp3
-rw-r--r--tests/server.lisp156
-rw-r--r--tests/suite.lisp259
3 files changed, 418 insertions, 0 deletions
diff --git a/tests/package.lisp b/tests/package.lisp
new file mode 100644
index 0000000..62113e3
--- /dev/null
+++ b/tests/package.lisp
@@ -0,0 +1,3 @@
+(defpackage #:cepis/tests
+ (:use #:common-lisp #:fiveam #:cepis)
+ (:export #:cepis #:run-tests))
diff --git a/tests/server.lisp b/tests/server.lisp
new file mode 100644
index 0000000..7725485
--- /dev/null
+++ b/tests/server.lisp
@@ -0,0 +1,156 @@
+(in-package #:cepis/tests)
+
+(defstruct (fake-server (:constructor %make-fake-server))
+ "A throwaway HTTP server on a random loopback port.
+
+The suite points a client at it with :base-url, so the code path exercised is
+the one that runs in production, request construction and error body reading
+included. Nothing is stubbed and no test touches the network."
+ socket
+ thread
+ port
+ (routes (make-hash-table :test #'equal))
+ (calls '())
+ (handler nil)
+ (lock (bt:make-lock))
+ (running t))
+
+(defun reason-phrase (status)
+ (case status
+ (200 "OK")
+ (400 "Bad Request")
+ (404 "Not Found")
+ (429 "Too Many Requests")
+ (500 "Internal Server Error")
+ (503 "Service Unavailable")
+ (t "Status")))
+
+(defun reply (status body &optional headers)
+ "A response the fake server can send: status line, body, extra headers."
+ (list status body headers))
+
+(defun respond (server path response)
+ "Register RESPONSE for PATH.
+
+RESPONSE is one `reply', or a list of them headed by :sequence — in which case
+each request consumes the next one, which is how retrying is tested. The last
+entry repeats once the list runs out."
+ (setf (gethash path (fake-server-routes server)) response))
+
+(defun next-response (server path)
+ "Record the call and return the response registered for PATH."
+ (bt:with-lock-held ((fake-server-lock server))
+ (push path (fake-server-calls server))
+ (let ((handler (fake-server-handler server)))
+ (if handler
+ (funcall handler path)
+ (let ((response (gethash path (fake-server-routes server))))
+ (cond ((null response)
+ (reply 404 "{\"erro\":\"cep_nao_encontrado\"}"))
+ ((eq (first response) :sequence)
+ (let ((remaining (rest response)))
+ (when (rest remaining)
+ (setf (gethash path (fake-server-routes server))
+ (cons :sequence (rest remaining))))
+ (first remaining)))
+ (t response)))))))
+
+(defun call-count (server prefix)
+ "How many requests so far began with PREFIX."
+ (bt:with-lock-held ((fake-server-lock server))
+ (count-if (lambda (path)
+ (and (>= (length path) (length prefix))
+ (string= prefix path :end2 (length prefix))))
+ (fake-server-calls server))))
+
+(defun total-calls (server)
+ (bt:with-lock-held ((fake-server-lock server))
+ (length (fake-server-calls server))))
+
+(defun read-http-line (stream)
+ "Read one CRLF-terminated line of a request, or NIL at end of input."
+ (let ((line (make-string-output-stream))
+ (any nil))
+ (loop for byte = (read-byte stream nil nil)
+ do (cond ((null byte) (return))
+ ((= byte 10) (setf any t) (return))
+ ((= byte 13))
+ (t (setf any t) (write-char (code-char byte) line))))
+ (when any
+ (get-output-stream-string line))))
+
+(defun write-http-response (stream status body headers)
+ (let* ((data (babel:string-to-octets body :encoding :utf-8))
+ (head (with-output-to-string (out)
+ (format out "HTTP/1.1 ~d ~a~c~c" status (reason-phrase status)
+ #\Return #\Newline)
+ (loop for (name . value) in headers
+ do (format out "~a: ~a~c~c" name value #\Return #\Newline))
+ (format out "Content-Length: ~d~c~c" (length data) #\Return #\Newline)
+ (format out "Connection: close~c~c~c~c"
+ #\Return #\Newline #\Return #\Newline))))
+ (write-sequence (babel:string-to-octets head :encoding :latin-1) stream)
+ (write-sequence data stream)
+ (finish-output stream)))
+
+(defun handle-connection (server connection)
+ (let ((stream (usocket:socket-stream connection)))
+ (let ((request-line (read-http-line stream)))
+ (when request-line
+ (loop for header = (read-http-line stream)
+ while (and header (plusp (length header))))
+ (let* ((parts (uiop:split-string request-line :separator " "))
+ (path (or (second parts) "/")))
+ (destructuring-bind (status body &optional headers)
+ (next-response server path)
+ (write-http-response stream status body headers)))))))
+
+(defun serve-loop (server)
+ (loop while (fake-server-running server)
+ do (when (usocket:wait-for-input (fake-server-socket server)
+ :timeout 0.05 :ready-only t)
+ (let ((connection (ignore-errors
+ (usocket:socket-accept (fake-server-socket server)
+ :element-type '(unsigned-byte 8)))))
+ (when connection
+ (bt:make-thread
+ (lambda ()
+ (unwind-protect
+ (ignore-errors (handle-connection server connection))
+ (ignore-errors (usocket:socket-close connection))))
+ :name "cepis-test-connection"))))))
+
+(defun start-fake-server ()
+ (let* ((socket (usocket:socket-listen "127.0.0.1" 0
+ :reuse-address t
+ :element-type '(unsigned-byte 8)))
+ (server (%make-fake-server :socket socket
+ :port (usocket:get-local-port socket))))
+ (setf (fake-server-thread server)
+ (bt:make-thread (lambda () (serve-loop server)) :name "cepis-test-server"))
+ server))
+
+(defun stop-fake-server (server)
+ (setf (fake-server-running server) nil)
+ (ignore-errors (bt:join-thread (fake-server-thread server)))
+ (ignore-errors (usocket:socket-close (fake-server-socket server))))
+
+(defun fake-server-url (server)
+ (format nil "http://127.0.0.1:~d" (fake-server-port server)))
+
+(defmacro with-fake-server ((server client &rest client-args) &body body)
+ "Run BODY with SERVER listening and CLIENT pointed at it.
+
+The backoff is near zero because what is under test is the retry logic, not
+the ability of `sleep' to sleep."
+ `(let ((,server (start-fake-server)))
+ (unwind-protect
+ (let ((,client (apply #'make-client
+ :base-url (fake-server-url ,server)
+ (append (list ,@client-args)
+ (list :timeout 5.0
+ :backoff-base 0.001
+ :backoff-max 0.01)))))
+ (declare (ignorable ,client))
+ ,@body)
+ (stop-fake-server ,server))))
diff --git a/tests/suite.lisp b/tests/suite.lisp
new file mode 100644
index 0000000..4ef0c5f
--- /dev/null
+++ b/tests/suite.lisp
@@ -0,0 +1,259 @@
+(in-package #:cepis/tests)
+
+(def-suite cepis
+ :description "The whole cepis suite, run against a local HTTP server.")
+
+(in-suite cepis)
+
+(defparameter +se-fields+
+ "\"cep\":\"01001000\",\"logradouro\":\"Praça da Sé\",\"complemento\":\"lado ímpar\",\"unidade\":\"\",\"bairro\":\"Sé\",\"localidade\":\"São Paulo\",\"uf\":\"SP\",\"estado\":\"São Paulo\",\"regiao\":\"Sudeste\",\"ibge\":\"3550308\",\"gia\":\"1004\",\"ddd\":\"11\",\"siafi\":\"7107\"")
+
+(defparameter +csv-header+
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi")
+
+(defun se-json (&rest extra-fields)
+ "The fixture address, with EXTRA-FIELDS spliced in as raw JSON members."
+ (format nil "{~a~{,~a~}}" +se-fields+ extra-fields))
+
+(defun csv-body (&rest rows)
+ (format nil "~a~%~{~a~%~}" +csv-header+ rows))
+
+(defmacro signalled-message (condition-type &body body)
+ "Run BODY and return the message of the CONDITION-TYPE it signals, or NIL."
+ `(handler-case (progn ,@body nil)
+ (,condition-type (condition) (error-message condition))))
+
+(test normalisation-accepts-masked-and-unmasked
+ (dolist (value '("01001000" "01001-000" " 01001-000 " "01001.000"))
+ (is (string= "01001000" (normalize-cep value)))))
+
+(test normalisation-rejects-bad-format
+ (dolist (value '("" "123" "0100100a" "010010000" "abcdefgh"))
+ (signals invalid-cep (normalize-cep value))))
+
+(test normalisation-rejects-wrong-type
+ (signals invalid-cep (normalize-cep 1001000)))
+
+(test normalisation-is-a-parse-error
+ (signals parse-error (normalize-cep "123")))
+
+(test successful-lookup
+ (with-fake-server (server client)
+ (respond server "/01001000" (reply 200 (se-json)))
+ (let ((address (lookup "01001-000" :client client)))
+ (is (string= "São Paulo" (address-localidade address)))
+ (is (string= "SP" (address-uf address)))
+ (is (string= "01001-000" (address-formatted-cep address)))
+ (is (null (address-geo address))))))
+
+(test reads-the-coordinate-when-present
+ (with-fake-server (server client)
+ (respond server "/01310100"
+ (reply 200 (se-json "\"geo\":{\"lat\":-23.5,\"lon\":-46.6,\"raio_m\":120,\"precisao\":\"logradouro\"}")))
+ (let ((geo (address-geo (lookup "01310100" :client client))))
+ (is (< (abs (- -23.5d0 (geo-lat geo))) 1d-9))
+ (is (= 120 (geo-raio-m geo)))
+ (is (geo-precise-p geo)))))
+
+(test municipality-coordinate-is-not-precise
+ (with-fake-server (server client)
+ (respond server "/01001000"
+ (reply 200 (se-json "\"geo\":{\"lat\":-23.5,\"lon\":-46.6,\"raio_m\":22000,\"precisao\":\"localidade\"}")))
+ (let ((geo (address-geo (lookup "01001000" :client client))))
+ (is (not (geo-precise-p geo))))))
+
+(test malformed-geo-does-not-lose-the-address
+ (with-fake-server (server client)
+ (respond server "/01001000" (reply 200 (se-json "\"geo\":{\"lat\":\"not a number\"}")))
+ (let ((address (lookup "01001000" :client client)))
+ (is (null (address-geo address)))
+ (is (string= "São Paulo" (address-localidade address))))))
+
+(test new-api-field-does-not-break
+ (with-fake-server (server client)
+ (respond server "/01001000" (reply 200 (se-json "\"campo_do_futuro\":\"value\"")))
+ (let ((address (lookup "01001000" :client client)))
+ (is (string= "value" (gethash "campo_do_futuro" (address-raw address)))))))
+
+(test not-found
+ (with-fake-server (server client)
+ (respond server "/99999999"
+ (reply 404 "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":\"gone\"}"))
+ (signals cep-not-found (lookup "99999999" :client client))
+ (handler-case (lookup "99999999" :client client)
+ (cep-not-found (condition)
+ (is (string= "99999999" (cell-error-name condition)))))))
+
+(test not-found-is-not-retried
+ (with-fake-server (server client)
+ (respond server "/99999999" (reply 404 "{\"erro\":\"cep_nao_encontrado\"}"))
+ (signals cep-not-found (lookup "99999999" :client client))
+ (is (= 1 (call-count server "/99999999")))))
+
+(test invalid-cep-never-leaves-the-machine
+ (with-fake-server (server client)
+ (signals invalid-cep (lookup "123" :client client))
+ (is (zerop (total-calls server)))))
+
+(test api-400-becomes-invalid-cep
+ (with-fake-server (server client)
+ (respond server "/00000000"
+ (reply 400 "{\"erro\":\"cep_invalido\",\"mensagem\":\"rejected by the API\"}"))
+ (is (search "rejected by the API"
+ (or (signalled-message invalid-cep (lookup "00000000" :client client)) "")))))
+
+(test response-that-is-not-json
+ (with-fake-server (server client)
+ (respond server "/01001000" (reply 200 "<html>captive portal</html>"))
+ (signals invalid-response (lookup "01001000" :client client))))
+
+(test retries-on-500-then-succeeds
+ (with-fake-server (server client)
+ (respond server "/01001000"
+ (list :sequence
+ (reply 500 "{\"erro\":\"interno\"}")
+ (reply 200 (se-json))))
+ (is (string= "São Paulo" (address-localidade (lookup "01001000" :client client))))
+ (is (= 2 (call-count server "/01001000")))))
+
+(test exhausts-attempts-then-signals
+ (with-fake-server (server client)
+ (respond server "/01001000" (reply 503 "{\"erro\":\"interno\"}"))
+ (handler-case (progn (lookup "01001000" :client client) (fail "expected a server-error"))
+ (server-error (condition)
+ (is (= 503 (server-error-status condition)))))
+ (is (= 3 (call-count server "/01001000")))))
+
+(test max-attempts-one-disables-retry
+ (with-fake-server (server client :max-attempts 1)
+ (respond server "/01001000" (reply 500 "{}"))
+ (signals server-error (lookup "01001000" :client client))
+ (is (= 1 (call-count server "/01001000")))))
+
+(test 429-becomes-rate-limited-with-retry-after
+ (with-fake-server (server client)
+ (respond server "/01001000" (reply 429 "{}" '(("Retry-After" . "7"))))
+ (handler-case (progn (lookup "01001000" :client client) (fail "expected a rate-limited"))
+ (rate-limited (condition)
+ (is (= 7 (rate-limited-retry-after condition)))))))
+
+(test retry-after-honours-the-cap
+ (with-fake-server (server client)
+ (respond server "/01001000" (reply 429 "{}" '(("Retry-After" . "3600"))))
+ (let ((started (get-internal-real-time)))
+ (signals rate-limited (lookup "01001000" :client client))
+ (is (< (/ (- (get-internal-real-time) started)
+ internal-time-units-per-second)
+ 5)))))
+
+(test network-error-when-nothing-listens
+ (let ((client (make-client :base-url "http://127.0.0.1:1"
+ :max-attempts 2
+ :backoff-base 0.001
+ :backoff-max 0.01
+ :timeout 1.0)))
+ (signals network-error (lookup "01001000" :client client))))
+
+(test total-timeout-bounds-the-whole-call
+ (with-fake-server (server client
+ :max-attempts 50 :backoff-base 0.05
+ :backoff-max 0.05 :total-timeout 0.3)
+ (respond server "/01001000" (reply 500 "{}"))
+ (let ((started (get-internal-real-time)))
+ (signals server-error (lookup "01001000" :client client))
+ (is (< (/ (- (get-internal-real-time) started)
+ internal-time-units-per-second)
+ 5))
+ (is (< (call-count server "/01001000") 50)))))
+
+(test batch-preserves-order-and-marks-missing
+ (with-fake-server (server client)
+ (respond server "/csv?ceps=01001000%2C99999999%2C20040020"
+ (reply 200 (csv-body
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"
+ "99999999,,,,,,,,,,,,"
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001")))
+ (let ((result (lookup-many '("01001000" "99999999" "20040020") :client client)))
+ (is (= 3 (length result)))
+ (is (string= "São Paulo" (address-localidade (first result))))
+ (is (null (second result)))
+ (is (string= "RJ" (address-uf (third result)))))))
+
+(test batch-field-with-comma-is-parsed
+ (with-fake-server (server client)
+ (respond server "/csv?ceps=01001000"
+ (reply 200 (csv-body
+ "01001000,\"Rua Um, Dois\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1")))
+ (let ((result (lookup-many '("01001000") :client client)))
+ (is (string= "Rua Um, Dois" (address-logradouro (first result)))))))
+
+(test batch-carries-no-coordinate
+ (with-fake-server (server client)
+ (respond server "/csv?ceps=01001000"
+ (reply 200 (csv-body
+ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1")))
+ (is (null (address-geo (first (lookup-many '("01001000") :client client)))))))
+
+(test batch-empty-list-makes-no-request
+ (with-fake-server (server client)
+ (is (null (lookup-many '() :client client)))
+ (is (zerop (total-calls server)))))
+
+(test batch-chunks-above-the-api-limit
+ (with-fake-server (server client)
+ (let ((ceps (loop for index from 0 below 501 collect (format nil "~8,'0d" index)))
+ (calls 0))
+ (setf (fake-server-handler server)
+ (lambda (path)
+ (declare (ignore path))
+ (incf calls)
+ (reply 200 (apply #'csv-body
+ (mapcar (lambda (cep)
+ (format nil "~a,,,,,X,SP,São Paulo,Sudeste,1,,11,1" cep))
+ (if (= calls 1)
+ (subseq ceps 0 500)
+ (subseq ceps 500)))))))
+ (let ((result (lookup-many ceps :client client)))
+ (is (= 2 calls))
+ (is (= 501 (length result)))))))
+
+(test batch-truncated-response-does-not-shift
+ (with-fake-server (server client)
+ (respond server "/csv?ceps=01001000%2C20040020"
+ (reply 200 (csv-body
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1")))
+ (let ((result (lookup-many '("01001000" "20040020") :client client)))
+ (is (= 2 (length result)))
+ (is (null (second result))))))
+
+(test reads-health
+ (with-fake-server (server client)
+ (respond server "/health"
+ (reply 200 "{\"status\":\"ok\",\"dne\":\"26071\",\"ceps\":1596168,\"geo\":894489,\"base_gerada_em\":\"2026-07-24T03:48:28Z\"}"))
+ (let ((health (health :client client)))
+ (is (health-ok-p health))
+ (is (string= "26071" (health-dne health)))
+ (is (= 1596168 (health-ceps health)))
+ (is (string= "2026-07-24T03:48:28Z" (health-base-gerada-em health))))))
+
+(test degraded-health-does-not-signal
+ (with-fake-server (server client)
+ (respond server "/health"
+ (reply 503 "{\"status\":\"degradado\",\"dne\":\"\",\"ceps\":0,\"geo\":0}"))
+ (let ((health (health :client client)))
+ (is (not (health-ok-p health)))
+ (is (= 1 (call-count server "/health"))))))
+
+(test trailing-slash-is-not-duplicated
+ (is (string= "http://x" (client-base-url (make-client :base-url "http://x/")))))
+
+(test invalid-parameters-are-rejected
+ (signals error (make-client :max-attempts 0))
+ (signals error (make-client :timeout 0)))
+
+(test user-agent-identifies-the-library
+ (is (search "cepis-lisp/" (client-user-agent (make-client)))))
+
+(defun run-tests ()
+ "Run the whole suite. Returns true when every test passed."
+ (run! 'cepis))