diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:52:15 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:52:15 -0300 |
| commit | 511a0ab33b5b12a4cd8a1d4dca6c5dce50cdb6c1 (patch) | |
| tree | 462412b439743c2d04f1acf24f6557251d3a8265 | |
| parent | 535dbda6b48171f8115b455e54790de94d97d42a (diff) | |
| download | woocommerce-511a0ab33b5b12a4cd8a1d4dca6c5dce50cdb6c1.tar.gz | |
A CEP that fails at the checkout is almost never a random number. It is
a shopper who knows perfectly well which town they live in and got a
digit wrong, most often by reaching for the -000 that 957 municipalities
never received. The API now names the municipality such a number would
have belonged to, in the body of its own 404, so the message can say
"that would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899"
instead of leaving the shopper at a dead end with nothing to act on.
It stays an error. Nothing from it is written into a checkout field, on
either checkout and in either lookup mode: a CEP that does not exist has
no address, and filling one in from the municipality behind it means a
parcel delivered to the wrong town and a chargeback behind that. The one
module that reads the 404 body hands its callers a finished sentence and
never the data itself, so there is no shape in which it could leak into
a field later. The two CEPs quoted are the lowest and the highest that
really exist there, never a list and never a suggestion, because the
range is sparse enough that a number out of the middle of it would most
likely not exist either.
The widget correctly throws its 404 away without reading it, so the body
is fetched once more, on the miss path only and once per CEP. In proxy
mode PHP passes the municipality through with the 404 and caches it with
the miss, which is what keeps the two modes indistinguishable from the
browser. Anything missing or misshapen falls back to the message the
plugin has always shown.
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 32 | ||||
| -rw-r--r-- | assets/js/cepis-woocommerce-blocks.js | 39 | ||||
| -rw-r--r-- | assets/js/cepis-woocommerce-classic.js | 92 | ||||
| -rw-r--r-- | assets/js/cepis-woocommerce-faixa.js | 243 | ||||
| -rw-r--r-- | cepis-woocommerce.php | 4 | ||||
| -rw-r--r-- | includes/class-cepis-woo-assets.php | 21 | ||||
| -rw-r--r-- | includes/class-cepis-woo-lookup.php | 135 | ||||
| -rw-r--r-- | includes/class-cepis-woo-messages.php | 10 | ||||
| -rw-r--r-- | includes/class-cepis-woo-transport.php | 30 | ||||
| -rw-r--r-- | languages/cepis-woocommerce-pt_BR.mo | bin | 4292 -> 4693 bytes | |||
| -rw-r--r-- | languages/cepis-woocommerce-pt_BR.po | 12 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | test/checkout.test.js | 171 | ||||
| -rw-r--r-- | test/fake-api.js | 44 |
15 files changed, 806 insertions, 31 deletions
@@ -1,5 +1,5 @@ PLUGIN := cepis-woocommerce -VERSION := 0.1.0 +VERSION := 0.2.0 DIST := dist ZIP := $(DIST)/$(PLUGIN)-$(VERSION).zip @@ -78,6 +78,36 @@ message appears under the CEP field, styleable through Both fill billing and shipping. The cart block's shipping calculator is not covered. +## When the CEP does not exist + +A CEP that fails at the checkout is rarely a random number. It is usually a +shopper who knows perfectly well which town they live in and got a digit wrong +— most often by trying the `-000`, which 957 municipalities never received. +Areal/RJ, for one, answers from 25845-001 upwards and never at 25845-000. + +When the API says which municipality the number would have belonged to, so does +the message: + +> CEP not found. This number would be in Areal/RJ, which has CEPs from +> 25845-001 to 25849-899. Check the number. + +It is still an **error**. Nothing from it is ever written into a checkout field +— not the city, not the state, nothing at all. A CEP that does not exist has no +address, and filling one in from the municipality behind it means a parcel +delivered to the wrong town. + +The two CEPs quoted are the lowest and the highest that really exist there, not +a list of valid ones and not a suggestion. The range is sparse — 118 real CEPs +across nearly five thousand numbers, in Areal's case — so a number picked out of +the middle of it would most likely not exist either. + +When the API names no municipality, because the CEP falls in no range at all, +the message is the one it has always been. + +Reading it costs one extra request, on the miss path only and once per CEP. +Both checkouts do it and both lookup modes support it: with **This site** +selected the municipality is passed through with the 404 and cached with it. + Verified on WordPress 7.0.2, WooCommerce 10.9.4, PHP 8.3. `Requires at least` is declared as 6.9 because that is what WooCommerce 10.x itself requires; older WordPress was not tested. @@ -116,7 +146,7 @@ make # list the targets make test # stand a store up in Docker and drive the checkout in Chromium make lint # php -l, prettier --check, and phpcs when installed make format # apply prettier (writes) -make build # produce dist/cepis-woocommerce-0.1.0.zip +make build # produce dist/cepis-woocommerce-0.2.0.zip make check # lint, then test, then build make clean # remove build output and throw the test containers away ``` diff --git a/assets/js/cepis-woocommerce-blocks.js b/assets/js/cepis-woocommerce-blocks.js index bcbab93..4f4e2e3 100644 --- a/assets/js/cepis-woocommerce-blocks.js +++ b/assets/js/cepis-woocommerce-blocks.js @@ -131,6 +131,37 @@ } /** + * Say which municipality a CEP that does not exist would have been in. + * + * Replaces the notice already on screen — same id, so the block swaps the + * text rather than stacking a second error. The failure is reported + * immediately and refined a round trip later, because making the shopper wait + * on a second request before being told anything at all is worse than the + * message improving under them. + * + * Nothing here goes near `apply()`. A CEP the Correios do not have has no + * address, and the municipality in a 404 is a guess about which digit went + * wrong, not a place to ship to. + * + * @param {string} name Section name, `shipping` or `billing`. + * @param {string} digits The CEP that was not found. + * @param {string} shown The message already in the notice area. + */ + function explain(name, digits, shown) { + var helper = window.cepisWooCommerceFaixa; + + if (!helper) { + return; + } + + helper.message(digits, shown, function (message) { + if (message !== shown && progress[name].cep === digits) { + announce(message); + } + }); + } + + /** * Write a resolved address into the store. * * @param {string} name Section name, `shipping` or `billing`. @@ -185,7 +216,13 @@ if (progress[name].cep !== digits || error.code === "aborted") { return; } - announce(config.messages[error.code] || error.message); + + var shown = config.messages[error.code] || error.message; + announce(shown); + + if (error.code === "not_found") { + explain(name, digits, shown); + } }, ); } diff --git a/assets/js/cepis-woocommerce-classic.js b/assets/js/cepis-woocommerce-classic.js index 69d8068..481104c 100644 --- a/assets/js/cepis-woocommerce-classic.js +++ b/assets/js/cepis-woocommerce-classic.js @@ -23,6 +23,9 @@ var DIACRITIC = /[\u0300-\u036f]/g; + /** Class the widget gives the region it announces through. */ + var STATUS = "cepis-autofill-status"; + /** * Compare labels the way a person would: accents and case do not count. * @@ -123,6 +126,90 @@ } /** + * The `aria-live` region the widget announces through. + * + * The widget inserts it immediately after the CEP field and points the + * field's `aria-describedby` at it, but only once, when it attaches. + * WooCommerce re-renders the address fields afterwards — that is what + * `country_to_state_changed` does — and the attribute does not survive it + * while the region itself does. So the sibling is what is actually reliable + * here, and the surrounding form row is the fallback for a theme that wraps + * the input in something of its own. + * + * Never widened to a document-wide search: billing and shipping each have a + * region, and picking the wrong one would tell a shopper about the address + * they are not editing. + * + * @param {string} selector CSS selector for the CEP field. + * @returns {HTMLElement|null} + */ + function statusRegion(selector) { + var field = document.querySelector(selector); + + if (!field) { + return null; + } + + var sibling = field.nextElementSibling; + + if (sibling && sibling.classList && sibling.classList.contains(STATUS)) { + return sibling; + } + + var node = field.parentElement; + + for (var depth = 0; node && depth < 2; depth += 1) { + var region = node.querySelector("." + STATUS); + + if (region) { + return region; + } + + node = node.parentElement; + } + + return null; + } + + /** + * Say which municipality a CEP that does not exist would have been in. + * + * Rewrites the line the widget just wrote, and nothing else — no field is + * touched here, on this path, ever. The CEP was not found; there is no + * address to fill anything with, and inventing one from the municipality + * would send the parcel to the wrong town. + * + * Both guards before the write matter, because the answer arrives one round + * trip late: the shopper may have typed on, and the widget may have replaced + * the line with a "found" of its own in the meantime. + * + * @param {string} selector CSS selector for the CEP field. + * @param {string} cep The CEP that was not found. + */ + function explain(selector, cep) { + var helper = window.cepisWooCommerceFaixa; + var region = statusRegion(selector); + + if (!helper || !region) { + return; + } + + var shown = region.textContent; + + helper.message(cep, shown, function (message) { + var current = statusRegion(selector); + var field = document.querySelector(selector); + var typed = field ? String(field.value == null ? "" : field.value).replace(/\D+/g, "") : ""; + + if (message === shown || !current || current.textContent !== shown || typed !== cep) { + return; + } + + current.textContent = message; + }); + } + + /** * Attach one address section — billing or shipping. * * @param {Record<string, string>} map Selectors, keyed by the API's field names. @@ -143,6 +230,11 @@ onResult: function (address) { fillState(map.uf, address.uf, address.estado); }, + onError: function (error, context) { + if (error && "not_found" === error.code) { + explain(map.cep, context.cep); + } + }, }; Object.keys(map).forEach(function (field) { diff --git a/assets/js/cepis-woocommerce-faixa.js b/assets/js/cepis-woocommerce-faixa.js new file mode 100644 index 0000000..b911b03 --- /dev/null +++ b/assets/js/cepis-woocommerce-faixa.js @@ -0,0 +1,243 @@ +/** + * Naming the municipality a CEP that does not exist would have belonged to. + * + * A CEP that fails at the checkout is rarely a random number. It is almost + * always a shopper who knows perfectly well which town they live in and got a + * digit wrong — most often by trying the `-000`, which 957 municipalities never + * received. Areal/RJ, for instance, answers from 25845-001 upwards and never at + * 25845-000. The API says so in the body of its own 404, under `faixa`, and + * telling the shopper "that number would be in Areal/RJ" turns a dead end into + * something they can fix. + * + * **What comes back from here is a sentence, and only a sentence.** The `faixa` + * object never leaves this module, because a CEP that does not exist has no + * address: the municipality named in a 404 is a guess about a typo, not a place + * to ship to. Filling a city or a state from it would produce a parcel sent to + * the wrong town and a chargeback with it. Both checkout integrations therefore + * get a string they can only display. + * + * The body costs one extra request, because the widget — correctly — throws its + * 404 away without reading it. That request happens only on the miss path, only + * once per CEP however many sections ask for it, and never at all when the CEP + * resolves, which is the overwhelming majority of lookups. + * + * @module + */ + +(function () { + /** Eight digits, split where the mask goes. */ + var CEP = /^(\d{5})(\d{3})$/; + + /** Anything that is not a digit. */ + var NOT_DIGITS = /\D+/g; + + /** A `%1$s`-style placeholder, as WordPress writes them. */ + var PLACEHOLDER = /%(\d)\$s/g; + + /** + * One promise of a message per CEP, so retyping the same wrong number and the + * billing and shipping sections asking at once all cost a single request. + * + * @type {Record<string, Promise<string>>} + */ + var asked = {}; + + /** + * Whatever the page was configured with, read late: this file is a dependency + * of the integrations and loads before the inline configuration does. + * + * @returns {Record<string, any>} + */ + function settings() { + return window.cepisWooCommerce || {}; + } + + /** + * The digits of a CEP, however it was typed. + * + * @param {unknown} value Anything printable. + * @returns {string} + */ + function digits(value) { + return String(value == null ? "" : value).replace(NOT_DIGITS, ""); + } + + /** + * A CEP with its mask, or an empty string when it is not eight digits. + * + * @param {unknown} value Anything printable. + * @returns {string} + */ + function mask(value) { + var match = CEP.exec(String(value == null ? "" : value)); + + return match ? match[1] + "-" + match[2] : ""; + } + + /** + * Fill a translated template in. + * + * A placeholder with no value is left as it stands, so a translation with the + * wrong number of arguments degrades into visible nonsense rather than + * `undefined`. + * + * @param {string} template Message with `%1$s` placeholders. + * @param {string[]} values Values, in order. + * @returns {string} + */ + function fill(template, values) { + return template.replace(PLACEHOLDER, function (whole, index) { + var value = values[Number(index) - 1]; + + return "string" === typeof value ? value : whole; + }); + } + + /** + * A non-empty trimmed string, or "". + * + * @param {unknown} value Anything. + * @returns {string} + */ + function text(value) { + return "string" === typeof value ? value.trim() : ""; + } + + /** + * Turn a 404 body into the sentence the shopper gets, or "" when it does not + * name a municipality. + * + * Every step is a guard: `faixa` is absent for a CEP in no municipality's + * range at all, and the plugin must behave exactly as it did before whenever + * anything is missing or the wrong shape. + * + * The range is deliberately described as the lowest and the highest CEP that + * exist there, never as a list and never as a suggestion. It is sparse — about + * 118 real CEPs across nearly five thousand numbers in Areal's case — so a + * number picked out of the middle of it would most likely not exist either. + * + * @param {unknown} body The decoded 404 body. + * @returns {string} + */ + function describe(body) { + var messages = settings().messages || {}; + var faixa = body && "object" === typeof body && !Array.isArray(body) ? body.faixa : null; + + if (!faixa || "object" !== typeof faixa || Array.isArray(faixa)) { + return ""; + } + + var city = text(faixa.localidade); + var uf = text(faixa.uf); + + if ("" === city || "" === uf) { + return ""; + } + + var first = mask(faixa.cep_ini); + var last = mask(faixa.cep_fim); + var ranged = text(messages.not_found_range); + + if ("" !== first && "" !== last && "" !== ranged) { + return fill(ranged, [city, uf, first, last]); + } + + var plain = text(messages.not_found_city); + + return "" === plain ? "" : fill(plain, [city, uf]); + } + + /** + * Fetch the 404 body for one CEP. + * + * Resolves with `null` for anything that is not a 404 carrying JSON — + * including a CEP that has meanwhile started to exist, a network failure, or + * a store whose API answers something else entirely. Never rejects. + * + * @param {string} cep Eight digits. + * @returns {Promise<unknown>} + */ + function body(cep) { + var config = settings(); + var base = String(config.baseUrl || "").replace(/\/+$/, ""); + + if ("" === base || "function" !== typeof window.fetch) { + return Promise.resolve(null); + } + + var controller = + "function" === typeof window.AbortController ? new window.AbortController() : null; + var timer = controller + ? window.setTimeout(function () { + controller.abort(); + }, config.timeoutMs || 8000) + : 0; + + return window + .fetch(base + "/" + cep, { + method: "GET", + credentials: "omit", + headers: { Accept: "application/json" }, + signal: controller ? controller.signal : undefined, + }) + .then(function (response) { + return 404 === response.status ? response.text() : ""; + }) + .then(function (payload) { + try { + return JSON.parse(payload); + } catch (error) { + return null; + } + }) + .catch(function () { + return null; + }) + .then(function (decoded) { + window.clearTimeout(timer); + + return decoded; + }); + } + + /** + * Ask what a CEP that does not exist would have been. + * + * `done` is always called exactly once, with either a better message or the + * fallback unchanged. It is never called with anything but a string, and the + * caller is given no way to reach the underlying data. + * + * @param {string} cep The CEP that was not found. + * @param {string} fallback The message already on screen. + * @param {(message: string) => void} done Called with what to show. + */ + function message(cep, fallback, done) { + if ("function" !== typeof done) { + return; + } + + var number = digits(cep); + + if (8 !== number.length) { + done(fallback); + return; + } + + if (!asked[number]) { + asked[number] = body(number).then(describe, function () { + return ""; + }); + } + + asked[number].then( + function (better) { + done("" === better ? fallback : better); + }, + function () { + done(fallback); + }, + ); + } + + window.cepisWooCommerceFaixa = { message: message }; +})(); diff --git a/cepis-woocommerce.php b/cepis-woocommerce.php index b9ae502..e493008 100644 --- a/cepis-woocommerce.php +++ b/cepis-woocommerce.php @@ -3,7 +3,7 @@ * Plugin Name: CEP Autofill for WooCommerce * Plugin URI: https://cep.is * Description: Fills the WooCommerce checkout address from the CEP the shopper types, with official Correios data (DNE) from cep.is. - * Version: 0.1.0 + * Version: 0.2.0 * Requires at least: 6.9 * Tested up to: 7.0 * Requires PHP: 8.1 @@ -22,7 +22,7 @@ declare(strict_types=1); defined( 'ABSPATH' ) || exit; -define( 'CEPIS_WOO_VERSION', '0.1.0' ); +define( 'CEPIS_WOO_VERSION', '0.2.0' ); define( 'CEPIS_WOO_FILE', __FILE__ ); define( 'CEPIS_WOO_DIR', plugin_dir_path( __FILE__ ) ); define( 'CEPIS_WOO_URL', plugin_dir_url( __FILE__ ) ); diff --git a/includes/class-cepis-woo-assets.php b/includes/class-cepis-woo-assets.php index def6fe2..503441c 100644 --- a/includes/class-cepis-woo-assets.php +++ b/includes/class-cepis-woo-assets.php @@ -27,6 +27,15 @@ final class Cepis_Woo_Assets { private const WIDGET = 'cepis-autofill'; /** + * Script handle for the shared "which municipality was that" helper. + * + * Its own file rather than a copy inside each integration: both checkouts + * need it, there is no build step to share code for them, and one module + * means one cache of what has already been asked. + */ + private const FAIXA = 'cepis-woocommerce-faixa'; + + /** * Hook the enqueue. */ public static function init(): void { @@ -51,10 +60,18 @@ final class Cepis_Woo_Assets { true ); + wp_register_script( + self::FAIXA, + CEPIS_WOO_URL . 'assets/js/cepis-woocommerce-faixa.js', + array(), + CEPIS_WOO_VERSION, + true + ); + $handle = 'cepis-woocommerce-' . $checkout; $dependencies = 'blocks' === $checkout - ? array( self::WIDGET, 'wp-data', 'wc-blocks-data-store' ) - : array( self::WIDGET, 'jquery' ); + ? array( self::WIDGET, self::FAIXA, 'wp-data', 'wc-blocks-data-store' ) + : array( self::WIDGET, self::FAIXA, 'jquery' ); wp_enqueue_script( $handle, diff --git a/includes/class-cepis-woo-lookup.php b/includes/class-cepis-woo-lookup.php index 47e3f16..da2f25a 100644 --- a/includes/class-cepis-woo-lookup.php +++ b/includes/class-cepis-woo-lookup.php @@ -43,6 +43,32 @@ final class Cepis_Woo_Lookup { private const MISS_TTL = DAY_IN_SECONDS; /** + * The fields of a 404's `faixa` object that are passed on. + * + * A whitelist rather than the whole object, so that a field the API grows + * later cannot reach the checkout unreviewed. None of these is ever written + * into a checkout field — see {@see Cepis_Woo_Lookup::faixa()}. + */ + private const FAIXA_FIELDS = array( + 'localidade', + 'uf', + 'estado', + 'regiao', + 'ibge', + 'cep_ini', + 'cep_fim', + 'ceps', + ); + + /** + * The transport the client speaks through, kept so that the body of a 404 + * can be read back after the client has turned it into an exception. + * + * @var Cepis_Woo_Transport|null + */ + private static ?Cepis_Woo_Transport $transport = null; + + /** * Look a CEP up, from cache when possible. * * Never throws: every outcome is described by the returned status and body, @@ -57,21 +83,32 @@ final class Cepis_Woo_Lookup { $cached = get_transient( self::CACHE_PREFIX . $cep ); if ( is_array( $cached ) ) { - return isset( $cached['miss'] ) - ? self::not_found() - : array( - 'status' => 200, - 'body' => $cached, - 'retry_after' => null, - ); + if ( isset( $cached['miss'] ) ) { + $remembered = isset( $cached['faixa'] ) && is_array( $cached['faixa'] ) ? $cached['faixa'] : null; + + return self::not_found( $remembered ); + } + + return array( + 'status' => 200, + 'body' => $cached, + 'retry_after' => null, + ); } try { $address = self::client()->lookup( $cep ); } catch ( Cepis\CepNotFoundException ) { - set_transient( self::CACHE_PREFIX . $cep, array( 'miss' => true ), self::MISS_TTL ); + $faixa = self::faixa( self::transport()->last_body() ); + $miss = array( 'miss' => true ); + + if ( null !== $faixa ) { + $miss['faixa'] = $faixa; + } + + set_transient( self::CACHE_PREFIX . $cep, $miss, self::MISS_TTL ); - return self::not_found(); + return self::not_found( $faixa ); } catch ( Cepis\InvalidCepException ) { return array( 'status' => 400, @@ -144,7 +181,7 @@ final class Cepis_Woo_Lookup { maxAttempts: 2, totalTimeout: 8.0, userAgent: 'cepis-woocommerce/' . CEPIS_WOO_VERSION . ' (+https://cep.is)', - transport: new Cepis_Woo_Transport(), + transport: self::transport(), ); } @@ -152,18 +189,86 @@ final class Cepis_Woo_Lookup { } /** + * The transport, built once per request. + */ + private static function transport(): Cepis_Woo_Transport { + if ( null === self::$transport ) { + self::$transport = new Cepis_Woo_Transport(); + } + + return self::$transport; + } + + /** + * The municipality a missing CEP would have belonged to, out of the API's + * 404 body. + * + * **This is context, not an address.** A CEP that does not exist has no + * address, and nothing here may ever reach a checkout field: a shipping + * address filled from a CEP the Correios do not have is a parcel delivered + * somewhere else. It exists only so the shopper can be told what they most + * likely mistyped — the classic case being the `-000` that 957 + * municipalities never received. + * + * Returns `null` for anything unexpected, so a body that changed shape, or + * is not JSON at all, produces exactly the message this plugin has always + * shown. + * + * @param string $body The response body, as it arrived. + * + * @return array<string, int|string>|null The named fields, or null. + */ + private static function faixa( string $body ): ?array { + if ( '' === $body ) { + return null; + } + + $data = json_decode( $body, true ); + + if ( ! is_array( $data ) || ! isset( $data['faixa'] ) || ! is_array( $data['faixa'] ) ) { + return null; + } + + $faixa = array(); + + foreach ( self::FAIXA_FIELDS as $name ) { + if ( ! isset( $data['faixa'][ $name ] ) || ! is_scalar( $data['faixa'][ $name ] ) ) { + continue; + } + + $value = $data['faixa'][ $name ]; + + $faixa[ $name ] = is_int( $value ) ? $value : sanitize_text_field( (string) $value ); + } + + return array() === $faixa ? null : $faixa; + } + + /** * The API's own 404 body, reproduced so the browser cannot tell the two * modes apart. * + * `faixa` is **omitted** when there is none, never sent as null, which is + * what the API does and what the browser is written to expect. + * + * @param array<string, int|string>|null $faixa The municipality the CEP + * would have belonged to, when the API named one. + * * @return array{status:int, body:array<string, mixed>, retry_after:?int} */ - private static function not_found(): array { + private static function not_found( ?array $faixa = null ): array { + $body = array( + 'erro' => 'cep_nao_encontrado', + 'mensagem' => 'CEP não existe na base do DNE', + ); + + if ( null !== $faixa ) { + $body['faixa'] = $faixa; + } + return array( 'status' => 404, - 'body' => array( - 'erro' => 'cep_nao_encontrado', - 'mensagem' => 'CEP não existe na base do DNE', - ), + 'body' => $body, 'retry_after' => null, ); } diff --git a/includes/class-cepis-woo-messages.php b/includes/class-cepis-woo-messages.php index 201fe4e..93d3279 100644 --- a/includes/class-cepis-woo-messages.php +++ b/includes/class-cepis-woo-messages.php @@ -16,6 +16,12 @@ defined( 'ABSPATH' ) || exit; * * Keys are the widget's status kinds and error codes. Anything not listed * keeps the widget's own wording. + * + * `not_found_range` and `not_found_city` are the exception: they are not + * status kinds the widget knows, but templates the plugin's own JavaScript + * fills in when the API says which municipality a missing CEP would have + * belonged to. They live in the same table so that one filter, one text domain + * and one `.po` file cover everything the shopper is told. */ final class Cepis_Woo_Messages { @@ -33,6 +39,10 @@ final class Cepis_Woo_Messages { 'loading' => __( 'Looking the address up…', 'cepis-woocommerce' ), 'found' => __( 'Address found.', 'cepis-woocommerce' ), 'not_found' => __( 'CEP not found. Check the number or fill the address in by hand.', 'cepis-woocommerce' ), + /* translators: 1: municipality, e.g. Areal. 2: state code, e.g. RJ. 3: lowest CEP that exists there, e.g. 25845-001. 4: highest CEP that exists there, e.g. 25849-899. */ + 'not_found_range' => __( 'CEP not found. This number would be in %1$s/%2$s, which has CEPs from %3$s to %4$s. Check the number.', 'cepis-woocommerce' ), + /* translators: 1: municipality, e.g. Areal. 2: state code, e.g. RJ. */ + 'not_found_city' => __( 'CEP not found. This number would be in %1$s/%2$s. Check the number.', 'cepis-woocommerce' ), 'invalid' => __( 'Invalid CEP. Type all 8 digits.', 'cepis-woocommerce' ), 'rate_limited' => __( 'Too many lookups in a short time. Wait a few seconds and try again.', 'cepis-woocommerce' ), 'server' => __( 'The CEP service is unavailable. Fill the address in by hand.', 'cepis-woocommerce' ), diff --git a/includes/class-cepis-woo-transport.php b/includes/class-cepis-woo-transport.php index 888be54..b89a81c 100644 --- a/includes/class-cepis-woo-transport.php +++ b/includes/class-cepis-woo-transport.php @@ -24,6 +24,31 @@ defined( 'ABSPATH' ) || exit; final class Cepis_Woo_Transport implements Cepis\Transport { /** + * Body of the response to the most recent completed request. + * + * The client turns a 404 into a {@see Cepis\CepNotFoundException} and drops + * the body with it, which is the right shape for a library: a CEP that does + * not exist is an error, not a payload. The API nonetheless puts something + * useful in that body — the municipality the number would have belonged to — + * and this is the only place in the plugin that still has it. + * + * Kept here rather than handed to the client so that nothing downstream can + * mistake it for an address. {@see Cepis_Woo_Lookup} reads it, keeps the few + * fields that name a municipality, and passes them on inside the 404. + * + * @var string + */ + private string $last_body = ''; + + /** + * The body of the last response, or an empty string when the last request + * never completed. + */ + public function last_body(): string { + return $this->last_body; + } + + /** * Perform a single GET. * * @param string $url Absolute URL, query string included. @@ -33,6 +58,8 @@ final class Cepis_Woo_Transport implements Cepis\Transport { * @throws Cepis\NetworkException The request never completed. */ public function get( string $url, array $headers, float $timeout ): Cepis\Response { + $this->last_body = ''; + $response = wp_remote_get( $url, array( @@ -48,11 +75,12 @@ final class Cepis_Woo_Transport implements Cepis\Transport { } $response_headers = wp_remote_retrieve_headers( $response ); + $this->last_body = (string) wp_remote_retrieve_body( $response ); return new Cepis\Response( (int) wp_remote_retrieve_response_code( $response ), is_object( $response_headers ) ? $response_headers->getAll() : (array) $response_headers, - (string) wp_remote_retrieve_body( $response ) + $this->last_body ); } } diff --git a/languages/cepis-woocommerce-pt_BR.mo b/languages/cepis-woocommerce-pt_BR.mo Binary files differindex 961e3e8..78ac83d 100644 --- a/languages/cepis-woocommerce-pt_BR.mo +++ b/languages/cepis-woocommerce-pt_BR.mo diff --git a/languages/cepis-woocommerce-pt_BR.po b/languages/cepis-woocommerce-pt_BR.po index 1169624..1547469 100644 --- a/languages/cepis-woocommerce-pt_BR.po +++ b/languages/cepis-woocommerce-pt_BR.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"Project-Id-Version: CEP Autofill for WooCommerce 0.1.0\n" +"Project-Id-Version: CEP Autofill for WooCommerce 0.2.0\n" "Report-Msgid-Bugs-To: https://cep.is\n" "PO-Revision-Date: 2026-07-26 00:00+0000\n" "Last-Translator: Murilo Ijanc <[email protected]>\n" @@ -20,6 +20,16 @@ msgstr "Endereço encontrado." msgid "CEP not found. Check the number or fill the address in by hand." msgstr "CEP não encontrado. Confira o número ou preencha o endereço manualmente." +msgid "" +"CEP not found. This number would be in %1$s/%2$s, which has CEPs from %3$s " +"to %4$s. Check the number." +msgstr "" +"CEP não encontrado. Esse número seria de %1$s/%2$s, que tem CEPs de %3$s a " +"%4$s. Confira o número." + +msgid "CEP not found. This number would be in %1$s/%2$s. Check the number." +msgstr "CEP não encontrado. Esse número seria de %1$s/%2$s. Confira o número." + msgid "Invalid CEP. Type all 8 digits." msgstr "CEP inválido. Digite os 8 dígitos." diff --git a/package.json b/package.json index 4bf7efc..82c859e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cepis-woocommerce", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Test harness for CEP Autofill for WooCommerce: drives both WooCommerce checkouts in Chromium against a fake cep.is", "license": "ISC", diff --git a/test/checkout.test.js b/test/checkout.test.js index 074b13a..cce2575 100644 --- a/test/checkout.test.js +++ b/test/checkout.test.js @@ -168,7 +168,7 @@ describe("plugin", () => { }); test("declares the version WooCommerce reports", () => { - expect(wp(["plugin", "get", "cepis-woocommerce", "--field=version"])).toBe("0.1.0"); + expect(wp(["plugin", "get", "cepis-woocommerce", "--field=version"])).toBe("0.2.0"); }); }); @@ -280,6 +280,47 @@ describe("block checkout", () => { await close(); }, 90000); + + test("a CEP that does not exist names its municipality and still fills nothing", async () => { + const { page, close } = await checkout("/checkout/"); + + await settled(page); + await typeCep(page, "#shipping-postcode", "01001000"); + await page.waitForFunction( + () => document.querySelector("#shipping-address_1").value !== "", + undefined, + { timeout: 15000 }, + ); + + await typeCep(page, "#shipping-postcode", "25845000"); + await page.waitForFunction( + () => + document.body.innerText.includes( + "would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899", + ), + undefined, + { timeout: 15000 }, + ); + + const after = await page.evaluate(() => ({ + street: document.querySelector("#shipping-address_1").value, + city: document.querySelector("#shipping-city").value, + state: document.querySelector("#shipping-state").value, + store: JSON.stringify(window.wp.data.select("wc/store/cart").getCustomerData()), + })); + + // The address the previous, real CEP produced is still there, untouched. + expect(after.street).toBe("Praça da Sé"); + expect(after.city).toBe("São Paulo"); + expect(after.state).toBe("SP"); + + // And nothing from the 404 reached the data store the order is built from. + // A municipality named in an error is a guess about a typo, not an address. + expect(after.store).not.toContain("Areal"); + expect(after.store).not.toContain('"state":"RJ"'); + + await close(); + }, 90000); }); describe("classic checkout", () => { @@ -443,6 +484,82 @@ describe("classic checkout", () => { await close(); }, 90000); + + test("a CEP that does not exist names its municipality and still fills nothing", async () => { + const { page, errors, close } = await checkout("/checkout-classico/"); + + await ready(page); + await typeCep(page, "#billing_postcode", "01001000"); + await page.waitForFunction( + () => document.querySelector("#billing_address_1").value !== "", + undefined, + { timeout: 15000 }, + ); + + await typeCep(page, "#billing_postcode", "25845000"); + await page.waitForFunction( + () => + (document.querySelector(".cepis-autofill-status") || {}).textContent === + "CEP not found. This number would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899. Check the number.", + undefined, + { timeout: 15000 }, + ); + + const after = await page.evaluate(() => ({ + street: document.querySelector("#billing_address_1").value, + city: document.querySelector("#billing_city").value, + state: document.querySelector("#billing_state").value, + invalid: document.querySelector("#billing_postcode").getAttribute("aria-invalid"), + leaked: Array.from(document.querySelectorAll("input, select, textarea")) + .map((field) => String(field.value == null ? "" : field.value)) + .filter((value) => /areal|rio de janeiro|^RJ$/i.test(value)), + })); + + // What the previous, real CEP filled in is still there. + expect(after.street).toBe("Praça da Sé"); + expect(after.city).toBe("São Paulo"); + expect(after.state).toBe("SP"); + expect(after.invalid).toBe("true"); + + // The whole point: naming the municipality must not put it in a field. + // Shipping to a town the CEP does not belong to is a parcel gone astray. + expect(after.leaked).toEqual([]); + expect(errors).toEqual([]); + + await close(); + }, 90000); + + test("a faixa it cannot use falls back to the plain message", async () => { + const { page, errors, close } = await checkout("/checkout-classico/"); + const plain = "CEP not found. Check the number or fill the address in by hand."; + + await ready(page); + await typeCep(page, "#billing_postcode", "25849000"); + + await page.waitForFunction( + (expected) => + (document.querySelector(".cepis-autofill-status") || {}).textContent === expected, + plain, + { timeout: 15000 }, + ); + + // Long enough for the follow-up request to have come back and, if it were + // going to, made a mess of the line. + await page.waitForTimeout(3000); + + const after = await page.evaluate(() => ({ + status: (document.querySelector(".cepis-autofill-status") || {}).textContent, + street: document.querySelector("#billing_address_1").value, + city: document.querySelector("#billing_city").value, + })); + + expect(after.status).toBe(plain); + expect(after.street).toBe(""); + expect(after.city).toBe(""); + expect(errors).toEqual([]); + + await close(); + }, 90000); }); describe("proxy mode", () => { @@ -482,7 +599,7 @@ describe("proxy mode", () => { const served = api.state.requests.filter((entry) => entry.cep === "01001000"); expect(served.length).toBe(1); - expect(served[0].userAgent).toContain("cepis-woocommerce/0.1.0"); + expect(served[0].userAgent).toContain("cepis-woocommerce/0.2.0"); expect(errors).toEqual([]); await close(); @@ -516,6 +633,39 @@ describe("proxy mode", () => { expect(api.state.requests.filter((entry) => entry.cep === "20040020").length).toBe(1); }, 120000); + test("the municipality survives the proxy and its cache, and fills nothing", async () => { + api.reset(); + const { page, close } = await checkout("/checkout-classico/"); + + await ready(page); + await typeCep(page, "#billing_postcode", "25845000"); + + await page.waitForFunction( + () => + (document.querySelector(".cepis-autofill-status") || {}).textContent === + "CEP not found. This number would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899. Check the number.", + undefined, + { timeout: 20000 }, + ); + + const after = await page.evaluate(() => ({ + street: document.querySelector("#billing_address_1").value, + leaked: Array.from(document.querySelectorAll("input, select, textarea")) + .map((field) => String(field.value == null ? "" : field.value)) + .filter((value) => /areal|rio de janeiro|^RJ$/i.test(value)), + })); + + expect(after.street).toBe(""); + expect(after.leaked).toEqual([]); + + // One request reached the API. The lookup itself made it; the follow-up + // that reads the municipality out of the 404 body was served from the + // transient, which is only possible if the miss was cached with it. + expect(api.state.requests.filter((entry) => entry.cep === "25845000")).toHaveLength(1); + + await close(); + }, 90000); + test("the REST route refuses a request without a valid token", async () => { const context = await browser.newContext(); const page = await context.newPage(); @@ -564,6 +714,23 @@ describe("translations", () => { await close(); }, 90000); + + test("so is the message that names the municipality", async () => { + const { page, close } = await checkout("/checkout-classico/"); + + await ready(page); + await typeCep(page, "#billing_postcode", "25845000"); + + await page.waitForFunction( + () => + (document.querySelector(".cepis-autofill-status") || {}).textContent === + "CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de 25845-001 a 25849-899. Confira o número.", + undefined, + { timeout: 15000 }, + ); + + await close(); + }, 90000); }); describe("housekeeping", () => { diff --git a/test/fake-api.js b/test/fake-api.js index 4b6ac39..d787bf1 100644 --- a/test/fake-api.js +++ b/test/fake-api.js @@ -43,6 +43,39 @@ const ADDRESSES = { }, }; +/** + * The municipality a CEP that does not exist would have belonged to, keyed by + * the CEP that provokes it. + * + * `25845000` is the real-world shape of the whole problem: Areal/RJ answers + * from 25845-001 upwards and never at the `-000` a shopper reaches for. Any + * other missing CEP — `99999999` — belongs to no municipality at all, and the + * API then omits `faixa` entirely rather than sending it as null, which is what + * the plugin's fallback path has to keep working against. + */ +const RANGES = { + 25845000: { + localidade: "Areal", + uf: "RJ", + estado: "Rio de Janeiro", + regiao: "Sudeste", + ibge: "3300225", + cep_ini: "25845001", + cep_fim: "25849899", + ceps: 118, + }, + + // A faixa with no municipality in it. The real API does not send this, and + // the point of having it here is that the checkout must not care: an + // unusable faixa has to end in exactly the message the plugin has always + // shown, not in a broken sentence and not in a JavaScript error. + 25849000: { + uf: "RJ", + cep_ini: "25845001", + cep_fim: "25849899", + }, +}; + const HEADERS = { "Access-Control-Allow-Origin": "*", "Content-Type": "application/json; charset=utf-8", @@ -76,10 +109,13 @@ export function startFakeApi(port) { const address = ADDRESSES[cep]; if (!address) { - return new Response( - JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }), - { status: 404, headers: HEADERS }, - ); + const missing = { erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }; + + if (RANGES[cep]) { + missing.faixa = RANGES[cep]; + } + + return new Response(JSON.stringify(missing), { status: 404, headers: HEADERS }); } return new Response(JSON.stringify(address), { status: 200, headers: HEADERS }); |