(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") (defparameter +areal-faixa+ "\"faixa\":{\"localidade\":\"Areal\",\"uf\":\"RJ\",\"estado\":\"Rio de Janeiro\",\"regiao\":\"Sudeste\",\"ibge\":\"3300225\",\"cep_ini\":\"25845001\",\"cep_fim\":\"25849899\",\"ceps\":118}" "The band 25845000 falls in: the CEP that fails is the DNE's own start for Areal, which is why the band begins one number later.") (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 not-found-json (&rest extra-fields) "The 404 body, with EXTRA-FIELDS spliced in as raw JSON members." (format nil "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":\"CEP não existe na base do DNE\"~{,~a~}}" 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 not-found-carries-the-band-it-falls-in (with-fake-server (server client) (respond server "/25845000" (reply 404 (not-found-json +areal-faixa+))) (handler-case (progn (lookup "25845000" :client client) (fail "expected a cep-not-found")) (cep-not-found (condition) (let ((faixa (cep-not-found-faixa condition))) (is (string= "25845000" (cell-error-name condition))) (is (string= "Areal" (faixa-localidade faixa))) (is (string= "RJ" (faixa-uf faixa))) (is (string= "Rio de Janeiro" (faixa-estado faixa))) (is (string= "Sudeste" (faixa-regiao faixa))) (is (string= "3300225" (faixa-ibge faixa))) ;; The ends are the CEPs that exist, not the bounds the Correios ;; publish: the DNE's own start for Areal is the 25845000 that just ;; failed. And the 118 of them are scattered over the ~4,900 numbers ;; in between, so the pair is not a list of valid CEPs. (is (string= "25845001" (faixa-cep-ini faixa))) (is (string= "25849899" (faixa-cep-fim faixa))) (is (= 118 (faixa-ceps faixa)))))))) (test the-band-does-not-make-a-not-found-succeed (with-fake-server (server client) (respond server "/25845000" (reply 404 (not-found-json +areal-faixa+))) (signals cep-not-found (lookup "25845000" :client client)) (is (= 1 (call-count server "/25845000"))))) (test not-found-without-a-band (with-fake-server (server client) (respond server "/99999999" (reply 404 (not-found-json))) (handler-case (progn (lookup "99999999" :client client) (fail "expected a cep-not-found")) (cep-not-found (condition) (is (null (cep-not-found-faixa condition))))))) (test malformed-band-does-not-lose-the-not-found (dolist (field '("\"faixa\":\"Areal\"" "\"faixa\":{}" "\"faixa\":[]" "\"faixa\":null" "\"faixa\":{\"uf\":\"RJ\"}")) (with-fake-server (server client) (respond server "/25845000" (reply 404 (not-found-json field))) (handler-case (progn (lookup "25845000" :client client) (fail "expected a cep-not-found")) (cep-not-found (condition) (is (null (cep-not-found-faixa condition)))))))) (test not-found-body-that-is-not-json-still-signals-not-found (with-fake-server (server client) (respond server "/25845000" (reply 404 "captive portal")) (signals cep-not-found (lookup "25845000" :client client)))) (test the-report-names-the-municipality (with-fake-server (server client) (respond server "/25845000" (reply 404 (not-found-json +areal-faixa+))) (respond server "/99999999" (reply 404 (not-found-json))) (handler-case (progn (lookup "25845000" :client client) (fail "expected a cep-not-found")) (cep-not-found (condition) (is (search "Areal" (princ-to-string condition))))) (handler-case (progn (lookup "99999999" :client client) (fail "expected a cep-not-found")) (cep-not-found (condition) (is (search "99999999" (princ-to-string condition))))))) (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 "captive portal")) (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))