diff options
| -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 }); |