summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile2
-rw-r--r--README.md32
-rw-r--r--assets/js/cepis-woocommerce-blocks.js39
-rw-r--r--assets/js/cepis-woocommerce-classic.js92
-rw-r--r--assets/js/cepis-woocommerce-faixa.js243
-rw-r--r--cepis-woocommerce.php4
-rw-r--r--includes/class-cepis-woo-assets.php21
-rw-r--r--includes/class-cepis-woo-lookup.php135
-rw-r--r--includes/class-cepis-woo-messages.php10
-rw-r--r--includes/class-cepis-woo-transport.php30
-rw-r--r--languages/cepis-woocommerce-pt_BR.mobin4292 -> 4693 bytes
-rw-r--r--languages/cepis-woocommerce-pt_BR.po12
-rw-r--r--package.json2
-rw-r--r--test/checkout.test.js171
-rw-r--r--test/fake-api.js44
15 files changed, 806 insertions, 31 deletions
diff --git a/Makefile b/Makefile
index 196cb5a..755c3aa 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
PLUGIN := cepis-woocommerce
-VERSION := 0.1.0
+VERSION := 0.2.0
DIST := dist
ZIP := $(DIST)/$(PLUGIN)-$(VERSION).zip
diff --git a/README.md b/README.md
index be2a9f3..2233555 100644
--- a/README.md
+++ b/README.md
@@ -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
index 961e3e8..78ac83d 100644
--- a/languages/cepis-woocommerce-pt_BR.mo
+++ b/languages/cepis-woocommerce-pt_BR.mo
Binary files differ
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 });