summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:49:48 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:49:48 -0300
commitf979d07d23098b96e758b3feb555cd06d85d9b2d (patch)
tree47298197a17a3d6c438a1f4cc7cb61afd390cf05 /tests
downloadcobol-f979d07d23098b96e758b3feb555cd06d85d9b2d.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests')
-rw-r--r--tests/cepis-tests.cob761
-rw-r--r--tests/server.py254
2 files changed, 1015 insertions, 0 deletions
diff --git a/tests/cepis-tests.cob b/tests/cepis-tests.cob
new file mode 100644
index 0000000..4bc1a11
--- /dev/null
+++ b/tests/cepis-tests.cob
@@ -0,0 +1,761 @@
+IDENTIFICATION DIVISION.
+PROGRAM-ID. CEPIS-TESTS.
+
+*> ---------------------------------------------------------------------
+*> The suite. Run it through tests/server.py, which binds a loopback
+*> port, exports CEPIS_TEST_URL and runs this program:
+*>
+*> python3 tests/server.py ./build/cepis-tests
+*>
+*> Every test goes through the real transport against that server, so
+*> what is exercised is the code that runs in production -- request
+*> construction, header handling and error-body reading included. It
+*> doubles as the test that CFG-BASE-URL works: without it none of this
+*> would reach anything.
+*>
+*> The process exits non-zero if any check fails.
+*> ---------------------------------------------------------------------
+
+DATA DIVISION.
+WORKING-STORAGE SECTION.
+COPY "cepis.cpy".
+COPY "cepis-http.cpy".
+
+01 WS-BASE PIC X(200).
+01 WS-CHECKS PIC 9(4) VALUE 0.
+01 WS-FAILURES PIC 9(4) VALUE 0.
+01 WS-LABEL PIC X(56).
+01 WS-DETAIL PIC X(200).
+01 WS-OK PIC X.
+01 WS-TALLY PIC 9(4).
+01 WS-I PIC 9(4).
+01 WS-COUNT PIC 9(9).
+01 WS-COUNT-BEFORE PIC 9(9).
+01 WS-COUNT-PATH PIC X(40).
+01 WS-NUM8 PIC 9(8).
+01 WS-T0 PIC S9(18) COMP-5.
+01 WS-T1 PIC S9(18) COMP-5.
+01 WS-ELAPSED PIC S9(18).
+01 WS-SAVED-ATTEMPTS PIC 9(4).
+01 WS-SAVED-RETRY PIC S9(9).
+
+01 WS-GOOD-TABLE.
+ 05 FILLER PIC X(20) VALUE "01001000".
+ 05 FILLER PIC X(20) VALUE "01001-000".
+ 05 FILLER PIC X(20) VALUE " 01001-000 ".
+ 05 FILLER PIC X(20) VALUE "01001.000".
+01 WS-GOODS REDEFINES WS-GOOD-TABLE.
+ 05 WS-GOOD OCCURS 4 TIMES PIC X(20).
+
+01 WS-BAD-TABLE.
+ 05 FILLER PIC X(20) VALUE SPACES.
+ 05 FILLER PIC X(20) VALUE "123".
+ 05 FILLER PIC X(20) VALUE "0100100a".
+ 05 FILLER PIC X(20) VALUE "010010000".
+ 05 FILLER PIC X(20) VALUE "abcdefgh".
+01 WS-BADS REDEFINES WS-BAD-TABLE.
+ 05 WS-BAD OCCURS 5 TIMES PIC X(20).
+
+PROCEDURE DIVISION.
+
+MAIN-START.
+ MOVE SPACES TO WS-BASE
+ ACCEPT WS-BASE FROM ENVIRONMENT "CEPIS_TEST_URL"
+ IF WS-BASE = SPACES
+ DISPLAY "CEPIS_TEST_URL is not set -- run through "
+ "tests/server.py"
+ MOVE 2 TO RETURN-CODE
+ STOP RUN
+ END-IF
+ DISPLAY "cepis 0.1.0 test suite against " FUNCTION TRIM (WS-BASE)
+ DISPLAY " "
+
+ PERFORM T-NORMALIZE-ACCEPTS
+ PERFORM T-NORMALIZE-REJECTS
+ PERFORM T-LOOKUP-SUCCESS
+ PERFORM T-GEO-PRECISE
+ PERFORM T-GEO-CITY-WIDE
+ PERFORM T-GEO-MALFORMED
+ PERFORM T-UNKNOWN-FIELDS
+ PERFORM T-LONG-VALUE-FLAGGED
+ PERFORM T-NOT-FOUND
+ PERFORM T-NOT-FOUND-NOT-RETRIED
+ PERFORM T-INVALID-NEVER-LEAVES
+ PERFORM T-API-400
+ PERFORM T-NOT-JSON
+ PERFORM T-RETRY-THEN-SUCCESS
+ PERFORM T-RETRY-EXHAUSTED
+ PERFORM T-ONE-ATTEMPT-NO-RETRY
+ PERFORM T-RATE-LIMITED
+ PERFORM T-RETRY-AFTER-CAPPED
+ PERFORM T-RETRY-AFTER-HTTP-DATE
+ PERFORM T-NETWORK-ERROR
+ PERFORM T-TOTAL-TIMEOUT
+ PERFORM T-BATCH-ORDER
+ PERFORM T-BATCH-QUOTING
+ PERFORM T-BATCH-MALFORMED-ENTRY
+ PERFORM T-BATCH-EMPTY
+ PERFORM T-BATCH-TRUNCATED
+ PERFORM T-BATCH-CHUNKING
+ PERFORM T-BATCH-OVER-CAPACITY
+ PERFORM T-HEALTH
+ PERFORM T-HEALTH-DEGRADED
+ PERFORM T-TRAILING-SLASH
+ PERFORM T-DEFAULT-USER-AGENT
+
+ DISPLAY " "
+ DISPLAY WS-CHECKS " checks, " WS-FAILURES " failed"
+ IF WS-FAILURES > 0
+ MOVE 1 TO RETURN-CODE
+ ELSE
+ MOVE 0 TO RETURN-CODE
+ END-IF
+ STOP RUN.
+
+*> ===================================================================
+*> Fixtures
+*> ===================================================================
+FRESH-CONFIG.
+ CALL "CEPIS-DEFAULT-CONFIG" USING CEPIS-CONFIG
+ MOVE WS-BASE TO CFG-BASE-URL
+ MOVE 5000 TO CFG-TIMEOUT-MS
+ MOVE 3 TO CFG-MAX-ATTEMPTS
+ MOVE 1 TO CFG-BACKOFF-BASE-MS
+ MOVE 10 TO CFG-BACKOFF-MAX-MS
+ MOVE 0 TO CFG-TOTAL-TIMEOUT-MS.
+
+CHECK.
+ ADD 1 TO WS-CHECKS
+ IF WS-OK = "Y"
+ DISPLAY "ok " WS-LABEL
+ ELSE
+ ADD 1 TO WS-FAILURES
+ DISPLAY "FAIL " WS-LABEL " -- " FUNCTION TRIM (WS-DETAIL)
+ END-IF
+ MOVE SPACES TO WS-DETAIL
+ MOVE "N" TO WS-OK.
+
+*> The fake server counts requests per path; that is how a 404 not being
+*> retried, or 501 CEPs becoming two requests, is proved rather than
+*> assumed.
+COUNT-FOR.
+ PERFORM FRESH-CONFIG
+ MOVE 1 TO CFG-MAX-ATTEMPTS
+ MOVE SPACES TO HTTPQ-PATH
+ STRING "/count?path=" FUNCTION TRIM (WS-COUNT-PATH)
+ DELIMITED BY SIZE INTO HTTPQ-PATH
+ END-STRING
+ COMPUTE HTTPQ-PATH-LEN =
+ 12 + FUNCTION LENGTH (FUNCTION TRIM (WS-COUNT-PATH))
+ MOVE "N" TO HTTPQ-ALLOW-503
+ MOVE 0 TO WS-COUNT
+ CALL "CEPIS-REQUEST" USING CEPIS-CONFIG CEPIS-HTTP-REQUEST
+ CEPIS-HTTP-RESPONSE CEPIS-STATUS
+ END-CALL
+ IF CEPIS-OK AND HTTP-BODY-LEN > 0
+ COMPUTE WS-COUNT =
+ FUNCTION NUMVAL (HTTP-BODY (1:HTTP-BODY-LEN))
+ END-IF.
+
+*> ===================================================================
+*> Local validation
+*> ===================================================================
+T-NORMALIZE-ACCEPTS.
+ PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > 4
+ MOVE WS-GOOD (WS-I) TO CEPIS-CEP-INPUT
+ CALL "CEPIS-NORMALIZE-CEP"
+ USING CEPIS-CEP-INPUT CEPIS-CEP CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND CEPIS-CEP = "01001000"
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE SPACES TO WS-LABEL
+ STRING "normalize accepts " WS-GOOD (WS-I)
+ DELIMITED BY SIZE INTO WS-LABEL
+ END-STRING
+ MOVE CEPIS-CEP TO WS-DETAIL
+ PERFORM CHECK
+ END-PERFORM.
+
+T-NORMALIZE-REJECTS.
+ PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > 5
+ MOVE WS-BAD (WS-I) TO CEPIS-CEP-INPUT
+ CALL "CEPIS-NORMALIZE-CEP"
+ USING CEPIS-CEP-INPUT CEPIS-CEP CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-INVALID-CEP AND CEPIS-CEP = SPACES
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE SPACES TO WS-LABEL
+ STRING "normalize rejects '" WS-BAD (WS-I) "'"
+ DELIMITED BY SIZE INTO WS-LABEL
+ END-STRING
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK
+ END-PERFORM.
+
+*> ===================================================================
+*> Single lookup
+*> ===================================================================
+T-LOOKUP-SUCCESS.
+ PERFORM FRESH-CONFIG
+ MOVE "01001-000" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK
+ AND ADDR-LOCALIDADE = "São Paulo"
+ AND ADDR-UF = "SP"
+ AND ADDR-LOGRADOURO = "Praça da Sé"
+ AND ADDR-IBGE = "3550308"
+ AND ADDR-CEP-MASKED = "01001-000"
+ AND ADDR-GEO-ABSENT
+ AND ADDR-NOT-TRUNCATED
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "lookup returns the address, masked cep, no geo" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-GEO-PRECISE.
+ PERFORM FRESH-CONFIG
+ MOVE "01310100" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND ADDR-GEO-KNOWN AND ADDR-GEO-PRECISE
+ AND ADDR-GEO-RAIO-M = 120
+ AND ADDR-GEO-LAT < -23.5 AND ADDR-GEO-LAT > -23.6
+ AND ADDR-GEO-LON < -46.6 AND ADDR-GEO-LON > -46.7
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "geo present is read and reports precisao logradouro"
+ TO WS-LABEL
+ PERFORM CHECK.
+
+T-GEO-CITY-WIDE.
+ PERFORM FRESH-CONFIG
+ MOVE "01001001" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND ADDR-GEO-KNOWN AND ADDR-GEO-CITY-WIDE
+ AND NOT ADDR-GEO-PRECISE AND ADDR-GEO-RAIO-M = 22000
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a municipality centroid is not precise" TO WS-LABEL
+ PERFORM CHECK.
+
+T-GEO-MALFORMED.
+ PERFORM FRESH-CONFIG
+ MOVE "01001002" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND ADDR-GEO-ABSENT
+ AND ADDR-LOCALIDADE = "São Paulo"
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a malformed geo does not lose the address" TO WS-LABEL
+ PERFORM CHECK.
+
+T-UNKNOWN-FIELDS.
+ PERFORM FRESH-CONFIG
+ MOVE "01001003" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND ADDR-LOCALIDADE = "São Paulo"
+ AND ADDR-GEO-ABSENT
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "fields the client does not know are ignored" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-LONG-VALUE-FLAGGED.
+ PERFORM FRESH-CONFIG
+ MOVE "01001010" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND ADDR-WAS-TRUNCATED
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a value past the field width sets ADDR-TRUNCATED"
+ TO WS-LABEL
+ PERFORM CHECK.
+
+T-NOT-FOUND.
+ PERFORM FRESH-CONFIG
+ MOVE "99999999" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-NOT-FOUND AND ADDR-LOCALIDADE = SPACES
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a missing CEP is CEPIS-NOT-FOUND" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-NOT-FOUND-NOT-RETRIED.
+ MOVE "/99999999" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ MOVE "N" TO WS-OK
+ IF WS-COUNT = 1
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "404 is definitive and is not retried" TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "requests: " WS-COUNT
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+T-INVALID-NEVER-LEAVES.
+ PERFORM FRESH-CONFIG
+ MOVE "123" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-INVALID-CEP
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "/123" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ IF WS-COUNT NOT = 0
+ MOVE "N" TO WS-OK
+ END-IF
+ MOVE "a malformed CEP never leaves the machine" TO WS-LABEL
+ PERFORM CHECK.
+
+T-API-400.
+ PERFORM FRESH-CONFIG
+ MOVE "00000000" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE 0 TO WS-TALLY
+ INSPECT CEPIS-STATUS-MESSAGE
+ TALLYING WS-TALLY FOR ALL "rejected by the API"
+ MOVE "N" TO WS-OK
+ IF CEPIS-INVALID-CEP AND WS-TALLY = 1
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "an API 400 carries the API's own message" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-NOT-JSON.
+ PERFORM FRESH-CONFIG
+ MOVE "01001004" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-INVALID-RESPONSE
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a body that is not JSON is CEPIS-INVALID-RESPONSE"
+ TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+*> ===================================================================
+*> Retrying
+*> ===================================================================
+T-RETRY-THEN-SUCCESS.
+ PERFORM FRESH-CONFIG
+ MOVE "01001005" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND ADDR-LOCALIDADE = "São Paulo"
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "/01001005" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ IF WS-COUNT NOT = 2
+ MOVE "N" TO WS-OK
+ END-IF
+ MOVE "500 is retried and the second attempt succeeds" TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "requests: " WS-COUNT
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+T-RETRY-EXHAUSTED.
+ PERFORM FRESH-CONFIG
+ MOVE "01001006" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-SERVER-ERROR AND CEPIS-STATUS-HTTP = 503
+ AND CEPIS-STATUS-ATTEMPTS = 3
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "/01001006" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ IF WS-COUNT NOT = 3
+ MOVE "N" TO WS-OK
+ END-IF
+ MOVE "three attempts, then CEPIS-SERVER-ERROR" TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "requests: " WS-COUNT
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+T-ONE-ATTEMPT-NO-RETRY.
+ PERFORM FRESH-CONFIG
+ MOVE 1 TO CFG-MAX-ATTEMPTS
+ MOVE "01001012" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-SERVER-ERROR
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "/01001012" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ IF WS-COUNT NOT = 1
+ MOVE "N" TO WS-OK
+ END-IF
+ MOVE "CFG-MAX-ATTEMPTS of 1 disables retrying" TO WS-LABEL
+ PERFORM CHECK.
+
+T-RATE-LIMITED.
+ PERFORM FRESH-CONFIG
+ MOVE "01001007" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-RATE-LIMITED AND CEPIS-STATUS-RETRY-AFTER-MS = 7000
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "429 exposes Retry-After in seconds" TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "retry-after ms: " CEPIS-STATUS-RETRY-AFTER-MS
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+T-RETRY-AFTER-CAPPED.
+ PERFORM FRESH-CONFIG
+ MOVE "01001008" TO CEPIS-CEP-INPUT
+ CALL "cepis_monotonic_ms" USING WS-T0
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ CALL "cepis_monotonic_ms" USING WS-T1
+ COMPUTE WS-ELAPSED = WS-T1 - WS-T0
+ MOVE "N" TO WS-OK
+ IF CEPIS-RATE-LIMITED AND WS-ELAPSED < 3000
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "an absurd Retry-After is capped at the backoff maximum"
+ TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "elapsed ms: " WS-ELAPSED
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+T-RETRY-AFTER-HTTP-DATE.
+ PERFORM FRESH-CONFIG
+ MOVE "01001009" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-RATE-LIMITED
+ AND CEPIS-STATUS-RETRY-AFTER-MS >= 1000
+ AND CEPIS-STATUS-RETRY-AFTER-MS <= 8000
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "Retry-After is also read as an HTTP date" TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "retry-after ms: " CEPIS-STATUS-RETRY-AFTER-MS
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+T-NETWORK-ERROR.
+ PERFORM FRESH-CONFIG
+ MOVE "http://127.0.0.1:1" TO CFG-BASE-URL
+ MOVE 2 TO CFG-MAX-ATTEMPTS
+ MOVE 1000 TO CFG-TIMEOUT-MS
+ MOVE "01001000" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-NETWORK-ERROR AND CEPIS-STATUS-ATTEMPTS = 2
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a refused connection is CEPIS-NETWORK-ERROR" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-TOTAL-TIMEOUT.
+ PERFORM FRESH-CONFIG
+ MOVE 50 TO CFG-MAX-ATTEMPTS
+ MOVE 50 TO CFG-BACKOFF-BASE-MS
+ MOVE 50 TO CFG-BACKOFF-MAX-MS
+ MOVE 300 TO CFG-TOTAL-TIMEOUT-MS
+ MOVE "01001011" TO CEPIS-CEP-INPUT
+ CALL "cepis_monotonic_ms" USING WS-T0
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ CALL "cepis_monotonic_ms" USING WS-T1
+ COMPUTE WS-ELAPSED = WS-T1 - WS-T0
+ MOVE CEPIS-STATUS-ATTEMPTS TO WS-SAVED-ATTEMPTS
+ MOVE "N" TO WS-OK
+ IF CEPIS-SERVER-ERROR AND WS-ELAPSED < 3000
+ AND WS-SAVED-ATTEMPTS < 50
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "CFG-TOTAL-TIMEOUT-MS bounds the whole call" TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "elapsed ms: " WS-ELAPSED
+ " attempts: " WS-SAVED-ATTEMPTS
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+*> ===================================================================
+*> Batch
+*> ===================================================================
+T-BATCH-ORDER.
+ PERFORM FRESH-CONFIG
+ MOVE 3 TO BATCH-COUNT
+ MOVE "01001000" TO BATCH-REQUESTED (1)
+ MOVE "99999999" TO BATCH-REQUESTED (2)
+ MOVE "20040020" TO BATCH-REQUESTED (3)
+ CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH
+ CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK
+ AND BATCH-ENTRY-FOUND (1)
+ AND BAT-LOCALIDADE (1) = "São Paulo"
+ AND BATCH-ENTRY-MISSING (2)
+ AND BATCH-ENTRY-FOUND (3)
+ AND BAT-UF (3) = "RJ"
+ AND BAT-GEO-ABSENT (1)
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a missing CEP keeps its slot and nothing shifts" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-BATCH-QUOTING.
+ PERFORM FRESH-CONFIG
+ MOVE 1 TO BATCH-COUNT
+ MOVE "01001111" TO BATCH-REQUESTED (1)
+ CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH
+ CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND BATCH-ENTRY-FOUND (1)
+ AND BAT-LOGRADOURO (1) = "Rua Um, Dois"
+ AND BAT-COMPLEMENTO (1) = 'aspas "aqui"'
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "quoted CSV fields with commas and quotes are parsed"
+ TO WS-LABEL
+ MOVE BAT-LOGRADOURO (1) TO WS-DETAIL
+ PERFORM CHECK.
+
+T-BATCH-MALFORMED-ENTRY.
+ PERFORM FRESH-CONFIG
+ MOVE 3 TO BATCH-COUNT
+ MOVE "01001000" TO BATCH-REQUESTED (1)
+ MOVE "nonsense" TO BATCH-REQUESTED (2)
+ MOVE "20040020" TO BATCH-REQUESTED (3)
+ CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH
+ CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK
+ AND BATCH-ENTRY-FOUND (1)
+ AND BATCH-ENTRY-MISSING (2)
+ AND BATCH-ENTRY-FOUND (3)
+ AND BAT-UF (3) = "RJ"
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a malformed CEP is missing, not an error" TO WS-LABEL
+ PERFORM CHECK.
+
+T-BATCH-EMPTY.
+ MOVE "/csv" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ MOVE WS-COUNT TO WS-COUNT-BEFORE
+ PERFORM FRESH-CONFIG
+ MOVE 0 TO BATCH-COUNT
+ CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH
+ CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "/csv" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ IF WS-COUNT NOT = WS-COUNT-BEFORE
+ MOVE "N" TO WS-OK
+ END-IF
+ MOVE "an empty batch makes no request" TO WS-LABEL
+ PERFORM CHECK.
+
+T-BATCH-TRUNCATED.
+ PERFORM FRESH-CONFIG
+ MOVE 2 TO BATCH-COUNT
+ MOVE "01001000" TO BATCH-REQUESTED (1)
+ MOVE "00000099" TO BATCH-REQUESTED (2)
+ CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH
+ CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND BATCH-ENTRY-FOUND (1)
+ AND BATCH-ENTRY-MISSING (2)
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a short response pads rather than shifts" TO WS-LABEL
+ PERFORM CHECK.
+
+T-BATCH-CHUNKING.
+ MOVE "/csv" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ MOVE WS-COUNT TO WS-COUNT-BEFORE
+ PERFORM FRESH-CONFIG
+ MOVE 501 TO BATCH-COUNT
+ PERFORM VARYING WS-I FROM 1 BY 1 UNTIL WS-I > 501
+ COMPUTE WS-NUM8 = 70000000 + WS-I
+ MOVE WS-NUM8 TO BATCH-REQUESTED (WS-I)
+ END-PERFORM
+ CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH
+ CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND BATCH-ENTRY-FOUND (1)
+ AND BATCH-ENTRY-FOUND (500)
+ AND BATCH-ENTRY-FOUND (501)
+ AND BAT-LOCALIDADE (501) = "Cidade Sintetica"
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "/csv" TO WS-COUNT-PATH
+ PERFORM COUNT-FOR
+ COMPUTE WS-COUNT = WS-COUNT - WS-COUNT-BEFORE
+ IF WS-COUNT NOT = 2
+ MOVE "N" TO WS-OK
+ END-IF
+ MOVE "501 CEPs become two requests, not one 400" TO WS-LABEL
+ MOVE SPACES TO WS-DETAIL
+ STRING "requests: " WS-COUNT " status: " CEPIS-STATUS-MESSAGE
+ DELIMITED BY SIZE INTO WS-DETAIL
+ END-STRING
+ PERFORM CHECK.
+
+T-BATCH-OVER-CAPACITY.
+ PERFORM FRESH-CONFIG
+ MOVE 1001 TO BATCH-COUNT
+ CALL "CEPIS-LOOKUP-MANY" USING CEPIS-CONFIG CEPIS-BATCH
+ CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-INVALID-CEP
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE 0 TO BATCH-COUNT
+ MOVE "a BATCH-COUNT past the table is refused, not truncated"
+ TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+*> ===================================================================
+*> Health and configuration
+*> ===================================================================
+T-HEALTH.
+ PERFORM FRESH-CONFIG
+ CALL "CEPIS-HEALTH" USING CEPIS-CONFIG CEPIS-HEALTH CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND HEALTH-OK AND HEALTH-DNE = "26071"
+ AND HEALTH-CEPS = 1596168 AND HEALTH-GEO = 894489
+ AND HEALTH-BASE-GERADA-EM = "2026-07-24T03:48:28Z"
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "health reports the live database" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-HEALTH-DEGRADED.
+ PERFORM FRESH-CONFIG
+ MOVE SPACES TO CFG-BASE-URL
+ STRING FUNCTION TRIM (WS-BASE) "/degraded"
+ DELIMITED BY SIZE INTO CFG-BASE-URL
+ END-STRING
+ CALL "CEPIS-HEALTH" USING CEPIS-CONFIG CEPIS-HEALTH CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND NOT HEALTH-OK
+ AND HEALTH-STATUS = "degradado"
+ AND CEPIS-STATUS-ATTEMPTS = 1
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a degraded API is diagnosed, not retried" TO WS-LABEL
+ MOVE HEALTH-STATUS TO WS-DETAIL
+ PERFORM CHECK.
+
+T-TRAILING-SLASH.
+ PERFORM FRESH-CONFIG
+ MOVE SPACES TO CFG-BASE-URL
+ STRING FUNCTION TRIM (WS-BASE) "/"
+ DELIMITED BY SIZE INTO CFG-BASE-URL
+ END-STRING
+ MOVE "01001000" TO CEPIS-CEP-INPUT
+ CALL "CEPIS-LOOKUP" USING CEPIS-CONFIG CEPIS-CEP-INPUT
+ CEPIS-ADDRESS CEPIS-STATUS
+ END-CALL
+ MOVE "N" TO WS-OK
+ IF CEPIS-OK AND ADDR-UF = "SP"
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "a trailing slash on the base URL is dropped" TO WS-LABEL
+ MOVE CEPIS-STATUS-MESSAGE TO WS-DETAIL
+ PERFORM CHECK.
+
+T-DEFAULT-USER-AGENT.
+ CALL "CEPIS-DEFAULT-CONFIG" USING CEPIS-CONFIG
+ MOVE 0 TO WS-TALLY
+ INSPECT CFG-USER-AGENT TALLYING WS-TALLY FOR ALL "cepis-cobol/0.1.0"
+ MOVE "N" TO WS-OK
+ IF WS-TALLY = 1 AND CFG-BASE-URL = "https://cep.is"
+ AND CFG-MAX-ATTEMPTS = 3 AND CFG-BACKOFF-BASE-MS = 500
+ AND CFG-BACKOFF-MAX-MS = 8000
+ MOVE "Y" TO WS-OK
+ END-IF
+ MOVE "the defaults identify the library and its version"
+ TO WS-LABEL
+ MOVE CFG-USER-AGENT TO WS-DETAIL
+ PERFORM CHECK.
+
+END PROGRAM CEPIS-TESTS.
diff --git a/tests/server.py b/tests/server.py
new file mode 100644
index 0000000..46185a0
--- /dev/null
+++ b/tests/server.py
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+"""Fake cep.is for the COBOL test suite.
+
+Binds a loopback port, points CEPIS_TEST_URL at it, runs the command given
+on the argv and exits with that command's status. Standing an HTTP server up
+from COBOL is not practical; every route below is a fixture the driver in
+`cepis-tests.cob` asserts against, and no test touches the network.
+
+ python3 tests/server.py ./build/cepis-tests
+
+The counters behind `/count` are what let the driver prove that a 404 is not
+retried, that a 500 is, and that 501 CEPs become two batch requests.
+"""
+
+import email.utils
+import json
+import os
+import subprocess
+import sys
+import threading
+import time
+import urllib.parse
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+SE = {
+ "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",
+}
+
+CSV_COLUMNS = [
+ "cep",
+ "logradouro",
+ "complemento",
+ "unidade",
+ "bairro",
+ "localidade",
+ "uf",
+ "estado",
+ "regiao",
+ "ibge",
+ "gia",
+ "ddd",
+ "siafi",
+]
+
+CSV_ROWS = {
+ "01001000": [
+ "Praça da Sé", "lado ímpar", "", "Sé", "São Paulo", "SP",
+ "São Paulo", "Sudeste", "3550308", "1004", "11", "7107",
+ ],
+ "20040020": [
+ "Avenida Rio Branco", "", "", "Centro", "Rio de Janeiro", "RJ",
+ "Rio de Janeiro", "Sudeste", "3304557", "", "21", "6001",
+ ],
+ "01001111": [
+ "Rua Um, Dois", 'aspas "aqui"', "", "Centro", "São Paulo", "SP",
+ "São Paulo", "Sudeste", "3550308", "", "11", "7107",
+ ],
+}
+
+SYNTHETIC = [
+ "Rua Sintetica", "", "", "Centro", "Cidade Sintetica", "SP",
+ "São Paulo", "Sudeste", "3550308", "", "11", "7107",
+]
+
+DROPPED_CEP = "00000099"
+
+LONG_STREET = "Avenida " + ("Muito Comprida " * 20)
+
+COUNTS = {}
+COUNT_LOCK = threading.Lock()
+
+
+def with_geo(precisao, raio):
+ body = dict(SE)
+ body["geo"] = {
+ "lat": -23.567202,
+ "lon": -46.649659,
+ "raio_m": raio,
+ "precisao": precisao,
+ }
+ return body
+
+
+def csv_escape(value):
+ if any(c in value for c in (",", '"', "\n", "\r")):
+ return '"' + value.replace('"', '""') + '"'
+ return value
+
+
+def csv_body(ceps):
+ lines = [",".join(CSV_COLUMNS)]
+ for cep in ceps:
+ if cep == DROPPED_CEP:
+ continue
+ if cep in CSV_ROWS:
+ values = [cep] + CSV_ROWS[cep]
+ elif cep.startswith("7") and len(cep) == 8:
+ values = [cep] + SYNTHETIC
+ else:
+ values = [cep] + [""] * 12
+ lines.append(",".join(csv_escape(v) for v in values))
+ return "\n".join(lines) + "\n"
+
+
+def json_body(payload):
+ return json.dumps(payload, ensure_ascii=False)
+
+
+def bump(path):
+ with COUNT_LOCK:
+ COUNTS[path] = COUNTS.get(path, 0) + 1
+ return COUNTS[path]
+
+
+def route(path, query, hits):
+ """Return (status, body, headers) for one request."""
+ if path == "/01001000":
+ return 200, json_body(SE), {}
+ if path == "/01310100":
+ return 200, json_body(with_geo("logradouro", 120)), {}
+ if path == "/01001001":
+ return 200, json_body(with_geo("localidade", 22000)), {}
+ if path == "/01001002":
+ body = dict(SE)
+ body["geo"] = {"lat": "not a number"}
+ return 200, json_body(body), {}
+ if path == "/01001003":
+ body = dict(SE)
+ body["campo_do_futuro"] = "value"
+ body["objeto_do_futuro"] = {"a": [1, 2, {"lat": 9}]}
+ return 200, json_body(body), {}
+ if path == "/01001004":
+ return 200, "<html>captive portal</html>", {}
+ if path == "/01001010":
+ body = dict(SE)
+ body["logradouro"] = LONG_STREET
+ return 200, json_body(body), {}
+ if path == "/01001005":
+ if hits == 1:
+ return 500, json_body({"erro": "interno"}), {}
+ return 200, json_body(SE), {}
+ if path in ("/01001006", "/01001011"):
+ return 503, json_body({"erro": "interno"}), {}
+ if path == "/01001012":
+ return 500, json_body({"erro": "interno"}), {}
+ if path == "/01001007":
+ return 429, json_body({"erro": "rate"}), {"Retry-After": "7"}
+ if path == "/01001008":
+ return 429, json_body({"erro": "rate"}), {"Retry-After": "3600"}
+ if path == "/01001009":
+ when = email.utils.formatdate(time.time() + 4, usegmt=True)
+ return 429, json_body({"erro": "rate"}), {"Retry-After": when}
+ if path == "/99999999":
+ return 404, json_body(
+ {"erro": "cep_nao_encontrado", "mensagem": "gone"}
+ ), {}
+ if path == "/00000000":
+ return 400, json_body(
+ {"erro": "cep_invalido", "mensagem": "rejected by the API"}
+ ), {}
+ if path == "/health":
+ return 200, json_body(
+ {
+ "status": "ok",
+ "dne": "26071",
+ "ceps": 1596168,
+ "geo": 894489,
+ "base_gerada_em": "2026-07-24T03:48:28Z",
+ }
+ ), {}
+ if path == "/degraded/health":
+ return 503, json_body(
+ {"status": "degradado", "dne": "", "ceps": 0, "geo": 0}
+ ), {}
+ if path == "/csv":
+ raw = urllib.parse.parse_qs(query).get("ceps", [""])[0]
+ ceps = [c for c in raw.split(",") if c]
+ if len(ceps) > 500:
+ return 400, json_body(
+ {"erro": "muitos_ceps", "mensagem": "no máximo 500"}
+ ), {}
+ return 200, csv_body(ceps), {}
+ return 404, json_body({"erro": "cep_nao_encontrado"}), {}
+
+
+class Handler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def do_GET(self):
+ parsed = urllib.parse.urlsplit(self.path)
+ path = parsed.path
+
+ if path == "/count":
+ wanted = urllib.parse.parse_qs(parsed.query).get("path", [""])[0]
+ with COUNT_LOCK:
+ total = sum(
+ n for p, n in COUNTS.items() if p.startswith(wanted)
+ )
+ self.reply(200, str(total), {})
+ return
+
+ hits = bump(path)
+ status, body, headers = route(path, parsed.query, hits)
+ self.reply(status, body, headers)
+
+ def reply(self, status, body, headers):
+ data = body.encode("utf-8")
+ self.send_response(status)
+ for key, value in headers.items():
+ self.send_header(key, value)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def log_message(self, *args):
+ pass
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: server.py <command> [args...]", file=sys.stderr)
+ return 2
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ host, port = server.server_address[:2]
+ thread = threading.Thread(
+ target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
+ )
+ thread.start()
+
+ env = dict(os.environ)
+ env["CEPIS_TEST_URL"] = "http://{}:{}".format(host, port)
+ try:
+ return subprocess.call(sys.argv[1:], env=env)
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())