diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:28 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:28 -0300 |
| commit | ae9ad8a4c61b793f6859c84dac80b1c1b04b7b68 (patch) | |
| tree | 62271e7672dfc58f59dda2ea4dc539c89a1ea5ca /tests | |
| download | lisp-e70c29ecac7aee94b167621bca6289a27fe64d14.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/package.lisp | 3 | ||||
| -rw-r--r-- | tests/server.lisp | 156 | ||||
| -rw-r--r-- | tests/suite.lisp | 259 |
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)) |