diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 16:41:48 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 16:47:14 -0300 |
| commit | 535dbda6b48171f8115b455e54790de94d97d42a (patch) | |
| tree | 5c0410011e61a883bdf063ee612875a637fb1d22 | |
| download | woocommerce-29190494c82855f0f0ce7fb16f831c8f971022c5.tar.gz | |
Initial importv0.1.0
45 files changed, 4262 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a25e854 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.phpcs/ +*.log +.DS_Store diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..de753c5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Murilo Ijanc <[email protected]> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..196cb5a --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +PLUGIN := cepis-woocommerce +VERSION := 0.1.0 +DIST := dist +ZIP := $(DIST)/$(PLUGIN)-$(VERSION).zip + +PHP_LOCAL := $(shell command -v php 2>/dev/null) +ifeq ($(PHP_LOCAL),) +PHP := docker run --rm -v "$(CURDIR)":/app -w /app php:8.3-cli php +else +PHP := $(PHP_LOCAL) +endif + +PHPCS := docker run --rm -v "$(CURDIR)":/app -v "$(CURDIR)/.phpcs/vendor":/phpcs -w /app \ + php:8.3-cli /phpcs/bin/phpcs --standard=phpcs.xml.dist --no-colors + +SOURCES := cepis-woocommerce.php uninstall.php includes lib assets languages LICENSE README.md + +.DEFAULT_GOAL := help + +.PHONY: help install test lint format build check clean phpcs up down + +help: + @echo "targets:" + @echo " test stand the store up and drive the checkout in Chromium" + @echo " lint php -l, prettier --check, and phpcs when installed" + @echo " format apply prettier (writes)" + @echo " build produce $(ZIP)" + @echo " check lint, then test, then build" + @echo " clean remove build output and throw the test containers away" + @echo " phpcs fetch PHP_CodeSniffer and the WordPress standard" + @echo " up start the test store and leave it running" + @echo " down stop the test store" + +# node_modules is gitignored, so a fresh clone has no playwright-core and the +# suite dies before the first assertion. Depending on it here is what makes +# `make check` work on a machine that has only just cloned this. +node_modules: package.json + bun install + @touch node_modules + +install: node_modules + +test: node_modules + sh test/setup.sh + bun test test/checkout.test.js + +lint: node_modules + @find . -name '*.php' -not -path './node_modules/*' -not -path './.phpcs/*' -not -path './dist/*' \ + | sort | sed 's|^\./||' | xargs -n1 $(PHP) -l >/dev/null + @echo "php -l: no syntax errors" + @bunx prettier --check 'assets/js/cepis-woocommerce-*.js' 'test/*.js' + @if [ -x .phpcs/vendor/bin/phpcs ]; then \ + $(PHPCS); \ + else \ + echo "phpcs: not installed, run 'make phpcs' for the WordPress standard"; \ + fi + +format: + bunx prettier --write 'assets/js/cepis-woocommerce-*.js' 'test/*.js' + +phpcs: + sh tools/install-phpcs.sh + +build: clean-dist + @mkdir -p $(DIST)/$(PLUGIN) + @cp -R $(SOURCES) $(DIST)/$(PLUGIN)/ + @cd $(DIST) && zip -qr $(PLUGIN)-$(VERSION).zip $(PLUGIN) + @rm -rf $(DIST)/$(PLUGIN) + @unzip -l $(ZIP) | tail -1 + @echo "built $(ZIP)" + +check: lint test build + +up: + sh test/setup.sh + +down: + docker compose -f test/compose.yml down + +clean: clean-dist + docker compose -f test/compose.yml down -v --remove-orphans 2>/dev/null || true + rm -rf node_modules .phpcs + +.PHONY: clean-dist +clean-dist: + @rm -rf $(DIST) diff --git a/README.md b/README.md new file mode 100644 index 0000000..be2a9f3 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# CEP Autofill for WooCommerce + +Fills the WooCommerce checkout address from the CEP the shopper types. Street, +neighbourhood, city and state, from official Correios data (DNE) via the +[public API at cep.is](https://cep.is). + +Works on **both** WooCommerce checkouts — the Cart/Checkout Blocks and the +classic `[woocommerce_checkout]` shortcode — see +[Supported checkouts](#supported-checkouts). + +No Composer, no npm, no build step: the PHP client and the JavaScript widget +are bundled in the plugin. + +## Install + +Download the `.zip` from `dist/` (or run `make build`), then **Plugins → Add +New → Upload Plugin** in WordPress. Activate it; WooCommerce must be active +first. + +By default the shopper's browser talks to cep.is directly, so there is nothing +to configure and no key to obtain. + +Requires WordPress 6.9 or newer, PHP 8.1 or newer, and WooCommerce. + +## Settings + +**WooCommerce → Settings → CEP autofill.** + +| setting | default | what it does | +| -------------------------- | -------- | ----------------------------------------------------------------------------------- | +| **Autofill** | on | look the address up while the shopper types | +| **Lookups go through** | browser | the browser straight to cep.is, or this site's REST API | +| **Fields already filled** | keep | keep what the shopper typed / only fill empty fields / always overwrite | +| **House number** | on | move the cursor to the house-number field after a hit (classic checkout) | +| **API address** | cep.is | point at your own instance of the API | + +### Lookups go through + +**The browser (default).** One cross-origin GET against a Cloudflare edge +cache. No PHP runs, nothing is billed to the store, and the answer arrives in +about twenty milliseconds. + +**This site.** The browser calls +`/wp-json/cepis/v1/lookup/{token}/{cep}` instead, and PHP calls cep.is through +`wp_remote_get()`. Choose this when a Content-Security-Policy on the checkout +forbids external requests, or when you want the answers cached server-side. +Answers are kept in transients: a hit for a week, since the database behind the +API is regenerated fortnightly and a week is half a refresh cycle; a "does not +exist" for a day, since a CEP absent today can appear in the next release. + +The route carries a short-lived token derived from the site's salts, and +answers `403` without one. It is registered only while this mode is on. + +### Fields already filled in + +`keep` — the default — fills empty fields, replaces values this plugin wrote +itself, and leaves anything the shopper typed alone, so correcting a street by +hand survives the next CEP. + +The state is the exception and is always replaced: the CEP decides it, and +WooCommerce pre-selects the store's own state on a fresh checkout. + +## Supported checkouts + +Both, and both verified in a real browser against a real store. + +| checkout | supported | how | +| ---------------------------------------------- | --------- | --------------------------------------------------------------------------- | +| **Cart/Checkout Blocks** (the default since WooCommerce 8.3) | yes | reads and writes the `wc/store/cart` data store; no DOM is touched | +| **Classic** `[woocommerce_checkout]` | yes | the widget fills the fields directly and announces progress in an `aria-live` region | + +On the block checkout the fields are React-controlled, so the plugin drives the +official `setShippingAddress` / `setBillingAddress` actions and reports failures +through the checkout's own notice area. On the classic checkout the status +message appears under the CEP field, styleable through +`.cepis-autofill-status[data-cepis-state="…"]`. + +Both fill billing and shipping. The cart block's shipping calculator is not +covered. + +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. + +## Brazilian address fields + +WooCommerce has no house-number and no neighbourhood field. When +[Brazilian Market on WooCommerce](https://wordpress.org/plugins/woocommerce-extra-checkout-fields-for-brazil/) +adds `billing_number` and `billing_neighborhood` to the classic checkout, this +plugin fills the neighbourhood and moves the cursor to the number. When they +are absent it fills what exists and does nothing else — the neighbourhood is +**not** written into `address_2`, which in WooCommerce means "apartment, suite, +unit" and would produce a wrong shipping label. + +The block checkout has no such fields and no plugin currently adds them. A +store that registers its own with +`woocommerce_register_additional_checkout_field()` can map it: + +```php +add_filter( 'cepis_woocommerce_blocks_fields', function ( $fields ) { + $fields['bairro'] = 'my-plugin/neighborhood'; + + return $fields; +} ); +``` + +The classic equivalent is `cepis_woocommerce_classic_fields`, which takes CSS +selectors per address section, for themes and one-page-checkout plugins that +rename the fields. `cepis_woocommerce_messages` overrides what the shopper is +told. + +## Development + +```sh +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 check # lint, then test, then build +make clean # remove build output and throw the test containers away +``` + +`make test` needs Docker and `bun`. It brings up WordPress, MySQL and WP-CLI +from `test/compose.yml`, installs and configures WooCommerce, and drives both +checkouts with Playwright's Chromium against a fake cep.is on the host, so no +test touches the network. Install Chromium with `bunx playwright install +chromium`, or point `CEPIS_CHROMIUM` at an existing binary. + +`make phpcs` fetches PHP_CodeSniffer and the WordPress standard into `.phpcs/`; +`make lint` picks them up automatically once they are there. + +### Refreshing the bundled dependencies + +`lib/cepis/` is the [PHP client](https://cep.is) and +`assets/js/cepis-autofill.min.js` is the +[autofill widget](https://cep.is), both copied in rather than installed, +because a WordPress site is deployed by copying files and never runs +`composer install`. + +```sh +tools/update-vendor.sh ../cepis-php ../cepis-autofill +``` + +That rebuilds the widget, copies both in, and leaves you a diff to review. + +## Licence + +ISC. See [LICENSE](LICENSE). diff --git a/assets/js/cepis-autofill.min.js b/assets/js/cepis-autofill.min.js new file mode 100644 index 0000000..1cb513e --- /dev/null +++ b/assets/js/cepis-autofill.min.js @@ -0,0 +1 @@ +(()=>{var{defineProperty:D,getOwnPropertyNames:E,getOwnPropertyDescriptor:h}=Object,f=Object.prototype.hasOwnProperty;function m(V){return this[V]}var g=(V)=>{var K=(M??=new WeakMap).get(V),J;if(K)return K;if(K=D({},"__esModule",{value:!0}),V&&typeof V==="object"||typeof V==="function"){for(var B of E(V))if(!f.call(K,B))D(K,B,{get:m.bind(V,B),enumerable:!(J=h(V,B))||J.enumerable})}return M.set(V,K),K},M;var c=(V)=>V;function u(V,K){this[V]=c.bind(null,K)}var p=(V,K)=>{for(var J in K)D(V,J,{get:K[J],enumerable:!0,configurable:!0,set:u.bind(K,J)})};var s={};p(s,{default:()=>o});var y="0.1.0";var k=/\D+/g,d=/[\u0300-\u036f]/g,l=["cep","logradouro","complemento","unidade","bairro","localidade","uf","estado","regiao","ibge","gia","ddd","siafi"],Q={idle:"",loading:"Buscando endereço…",found:"Endereço encontrado.",not_found:"CEP não encontrado. Confira o número ou preencha o endereço manualmente.",invalid:"CEP inválido. Digite os 8 dígitos.",rate_limited:"Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo.",server:"O serviço de CEP está indisponível. Preencha o endereço manualmente.",network:"Não foi possível consultar o CEP. Verifique sua conexão e tente de novo.",timeout:"A consulta do CEP demorou demais. Tente de novo ou preencha manualmente.",invalid_response:"Resposta inesperada do serviço de CEP. Preencha o endereço manualmente."};class L extends Error{constructor(V,K,J={}){super(K,J.cause===void 0?void 0:{cause:J.cause});this.name="CepisAutofillError",this.code=V,this.status=J.status,this.retryAfterMs=J.retryAfterMs,this.cep=J.cep}}function U(V){let K=String(V==null?"":V).replace(k,"");if(K.length!==8)throw new L("invalid",Q.invalid,{cep:K});return K}var R=new Map;function b(){R.clear()}function a(V){if(!V)return;let K=V.trim();if(/^\d+$/.test(K))return Number(K)*1000;let J=Date.parse(K);return Number.isNaN(J)?void 0:Math.max(0,J-Date.now())}function n(V,K){try{let J=JSON.parse(V);if(J&&typeof J.mensagem==="string"&&J.mensagem!=="")return J.mensagem}catch{return K}return K}async function G(V,K={}){let J=U(V),B=(K.baseUrl??"https://cep.is").replace(/\/+$/,""),N=`${B}|${J}`,q=R.get(N);if(q){if(q.address)return q.address;throw new L(q.code,q.message,{cep:J})}let F=new AbortController,Y=()=>F.abort();if(K.signal){if(K.signal.aborted)throw new L("aborted","consulta cancelada",{cep:J});K.signal.addEventListener("abort",Y)}let j=!1,_=setTimeout(()=>{j=!0,F.abort()},K.timeoutMs??8000),H,O;try{H=await fetch(`${B}/${J}`,{method:"GET",credentials:"omit",headers:{Accept:"application/json"},signal:F.signal}),O=await H.text()}catch(W){if(j)throw new L("timeout",Q.timeout,{cep:J,cause:W});if(F.signal.aborted)throw new L("aborted","consulta cancelada",{cep:J});throw new L("network",Q.network,{cep:J,cause:W})}finally{if(clearTimeout(_),K.signal)K.signal.removeEventListener("abort",Y)}if(H.status===404)throw R.set(N,{code:"not_found",message:Q.not_found}),new L("not_found",Q.not_found,{cep:J});if(H.status===400)throw new L("invalid",n(O,Q.invalid),{cep:J});if(H.status===429)throw new L("rate_limited",Q.rate_limited,{cep:J,retryAfterMs:a(H.headers.get("Retry-After"))});if(H.status>=500)throw new L("server",Q.server,{cep:J,status:H.status});if(!H.ok)throw new L("invalid_response",Q.invalid_response,{cep:J,status:H.status});let z;try{z=JSON.parse(O)}catch(W){throw new L("invalid_response",Q.invalid_response,{cep:J,cause:W})}if(!z||typeof z!=="object"||Array.isArray(z))throw new L("invalid_response",Q.invalid_response,{cep:J});return R.set(N,{address:z}),z}function T(V){if(!V)return null;if(typeof V==="string")return document.querySelector(V);return V.nodeType===1?V:null}function I(V){return String(V??"").normalize("NFD").replace(d,"").trim().toLowerCase()}function C(V,K){let J=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(V),"value");if(J&&J.set)J.set.call(V,K);else V.value=K;V.dispatchEvent(new Event("input",{bubbles:!0})),V.dispatchEvent(new Event("change",{bubbles:!0}))}function r(V,K){let J=K.filter(Boolean).map(I);for(let B of Array.from(V.options))if(J.includes(I(B.value))||J.includes(I(B.textContent)))return C(V,B.value),B.value;return null}function i(V,K,J){let B=String(V.value??"");if(K===!0)return!0;if(B.trim()==="")return!0;if(K===!1)return!1;return J.get(V)===B}var w=0;function A(V){if(!V||!V.cep)throw TypeError("attach() needs a `cep` selector or element");let K=(V.baseUrl??"https://cep.is").replace(/\/+$/,""),J=V.debounceMs??250,B=V.timeoutMs??8000,N=V.overwrite??"auto",q={...Q,...V.messages??{}},F=new WeakMap,Y={seq:0,timer:0,controller:null,region:null,describedBy:!1,detached:!1},j=()=>T(V.cep);function _(){if(V.status===!1)return null;if(Y.region&&Y.region.isConnected)return Y.region;if(V.status)Y.region=T(V.status);else{let $=j();if(!$)return null;let x=document.createElement("div");x.className="cepis-autofill-status",x.style.minHeight="1.25em",$.insertAdjacentElement("afterend",x),Y.region=x}let X=Y.region;if(!X)return null;if(X.setAttribute("role","status"),X.setAttribute("aria-live","polite"),X.setAttribute("aria-atomic","true"),!X.id)w+=1,X.id=`cepis-autofill-status-${w}`;let Z=j();if(Z&&!(Z.getAttribute("aria-describedby")??"").includes(X.id)){let $=Z.getAttribute("aria-describedby");Z.setAttribute("aria-describedby",$?`${$} ${X.id}`:X.id),Y.describedBy=!0}return X}function H(X){let Z=q[X]??"",$=_();if($)$.setAttribute("data-cepis-state",X==="found"||X==="loading"?X:X==="idle"?"idle":"error"),$.textContent=Z;let x=j();if(x){if(X==="loading")x.setAttribute("aria-busy","true");else x.removeAttribute("aria-busy");x.setAttribute("aria-invalid",X==="invalid"||X==="not_found"?"true":"false")}if(V.onStatus)V.onStatus(X,Z)}function O(X){for(let Z of l){if(Z==="cep"||!V[Z])continue;let $=T(V[Z]),x=X[Z];if(!$||typeof x!=="string"||x==="")continue;if(!i($,N,F))continue;if($.tagName==="SELECT"){if(r($,Z==="uf"?[x,X.estado]:Z==="estado"?[x,X.uf]:[x])!==null)F.set($,$.value);continue}C($,x),F.set($,x)}}async function z(X){Y.seq+=1;let Z=Y.seq;if(Y.controller)Y.controller.abort();let $=new AbortController;Y.controller=$,H("loading");try{let x=await G(X,{baseUrl:K,timeoutMs:B,signal:$.signal});if(Z!==Y.seq||Y.detached)return null;if(O(x),H("found"),V.onResult)V.onResult(x,{cep:X});let P=T(V.numero);if(V.focusNumero&&P&&document.activeElement===j())P.focus();return x}catch(x){if(Z!==Y.seq||Y.detached||x.code==="aborted")return null;let P=x instanceof L?x.code:"network";if(H(P),V.onError)V.onError(x,{cep:X});return null}finally{if(Y.controller===$)Y.controller=null}}function W(X){let Z=X.target;if(!(typeof V.cep==="string"?Z&&Z.nodeType===1&&Z.matches(V.cep):Z===V.cep))return;v(String(Z.value??""))}function v(X){clearTimeout(Y.timer);let Z=X.replace(k,"");if(Z.length!==8){if(Y.seq+=1,Y.controller)Y.controller.abort(),Y.controller=null;H(Z.length>8?"invalid":"idle");return}Y.timer=setTimeout(()=>z(Z),J)}return H("idle"),document.addEventListener("input",W,!0),document.addEventListener("change",W,!0),{detach(){if(Y.detached=!0,Y.seq+=1,clearTimeout(Y.timer),Y.controller)Y.controller.abort(),Y.controller=null;document.removeEventListener("input",W,!0),document.removeEventListener("change",W,!0);let X=j();if(X)X.removeAttribute("aria-busy"),X.removeAttribute("aria-invalid");if(Y.region&&Y.describedBy&&X){let Z=(X.getAttribute("aria-describedby")??"").split(/\s+/).filter(($)=>$&&$!==Y.region.id).join(" ");if(Z)X.setAttribute("aria-describedby",Z);else X.removeAttribute("aria-describedby")}if(Y.region&&!V.status)Y.region.remove();Y.region=null},lookup(X){clearTimeout(Y.timer);let Z=j(),$=X??(Z?Z.value:""),x;try{x=U($)}catch(P){if(H("invalid"),V.onError)V.onError(P,{cep:String($)});return Promise.resolve(null)}return z(x)},element:j}}var S={attach:A,lookup:G,normalizeCep:U,clearCache:b,CepisAutofillError:L,MESSAGES:Q,VERSION:y};if(typeof window<"u")window.CepisAutofill=S;var o=S;})(); diff --git a/assets/js/cepis-woocommerce-blocks.js b/assets/js/cepis-woocommerce-blocks.js new file mode 100644 index 0000000..bcbab93 --- /dev/null +++ b/assets/js/cepis-woocommerce-blocks.js @@ -0,0 +1,243 @@ +/** + * WooCommerce Cart/Checkout Blocks. + * + * The block checkout is React and its address inputs are controlled: a value + * written into the DOM from outside is discarded on the next render, and the + * `wc/store/cart` data store — which is what the order is actually built from — + * never hears about it. So nothing here touches a field. The postcode is read + * from the data store, and the resolved address is written back through the + * store's own `setShippingAddress` / `setBillingAddress` actions, which is the + * documented extension point. React then re-renders the inputs itself. + * + * Only the request layer is shared with the classic integration: + * `CepisAutofill.lookup` is the widget's HTTP client without its DOM half — + * same cache, same error codes, same handling of 400, 404, 429 and 5xx. + * + * @module + */ + +(function () { + var config = window.cepisWooCommerce; + var data = window.wp && window.wp.data; + + if (!config || !config.fields || !window.CepisAutofill || !data || !data.select) { + return; + } + + var CART = "wc/store/cart"; + var NOTICES = "core/notices"; + + /** + * Where a notice has to be rendered for the Checkout block to show it. + */ + var NOTICE_CONTEXT = "wc/checkout"; + + /** + * One id, so a new failure replaces the previous message instead of stacking. + */ + var NOTICE_ID = "cepis-woocommerce-lookup"; + + var SECTIONS = { + shipping: { address: "shippingAddress", write: "setShippingAddress" }, + billing: { address: "billingAddress", write: "setBillingAddress" }, + }; + + var progress = { + shipping: { cep: "", timer: 0, written: {} }, + billing: { cep: "", timer: 0, written: {} }, + }; + + /** + * The one field the shopper is never asked about. + * + * A street name is worth protecting — the shopper may know better than the + * database. A state is not: the CEP decides it. WooCommerce also starts the + * checkout with the store's own state already selected, which the `"auto"` + * rule would read as the shopper's choice and never touch, quietly filing a + * Rio address under São Paulo. + */ + var ALWAYS = "uf"; + + /** + * Whether a field may be written. + * + * Mirrors the widget's `overwrite` option: `"auto"` protects anything the + * shopper typed but replaces what a previous lookup wrote, so correcting the + * street by hand survives the next CEP. + * + * @param {string} field The API's name for the field. + * @param {string} current Value the store holds now. + * @param {string|undefined} written Value this plugin last wrote there. + * @returns {boolean} + */ + function writable(field, current, written) { + if (field === ALWAYS || config.overwrite === true) { + return true; + } + if (current.trim() === "") { + return true; + } + if (config.overwrite === false) { + return false; + } + return written === current; + } + + var pending = {}; + + /** + * Look a CEP up once, however many sections want it. + * + * With "use the same address for billing" on — the default — the shipping + * and the billing observer see the same postcode land in the same tick. The + * widget's own cache is only filled once a response is back, so without this + * the store would send two identical requests for every CEP a shopper types. + * + * @param {string} digits The eight digits. + * @returns {Promise<Record<string, any>>} + */ + function resolve(digits) { + if (!pending[digits]) { + var forget = function () { + delete pending[digits]; + }; + + pending[digits] = window.CepisAutofill.lookup(digits, { + baseUrl: config.baseUrl, + timeoutMs: config.timeoutMs, + }); + pending[digits].then(forget, forget); + } + + return pending[digits]; + } + + /** + * Show one message in the Checkout block's own notice area. + * + * @param {string} text Message, already translated. + */ + function announce(text) { + data.dispatch(NOTICES).createNotice("error", text, { + id: NOTICE_ID, + context: NOTICE_CONTEXT, + isDismissible: true, + }); + } + + /** Take the message away. */ + function silence() { + data.dispatch(NOTICES).removeNotice(NOTICE_ID, NOTICE_CONTEXT); + } + + /** + * Write a resolved address into the store. + * + * @param {string} name Section name, `shipping` or `billing`. + * @param {Record<string, any>} address The API's answer. + */ + function apply(name, address) { + var customer = data.select(CART).getCustomerData(); + var current = (customer && customer[SECTIONS[name].address]) || {}; + var written = progress[name].written; + var patch = {}; + var changed = false; + + Object.keys(config.fields).forEach(function (field) { + var key = config.fields[field]; + var value = address[field]; + + if (!key || typeof value !== "string" || value === "") { + return; + } + + var held = current[key] == null ? "" : String(current[key]); + if (!writable(field, held, written[key])) { + return; + } + + patch[key] = value; + written[key] = value; + changed = true; + }); + + if (changed) { + data.dispatch(CART)[SECTIONS[name].write](patch); + } + } + + /** + * Resolve one CEP and write what comes back. + * + * @param {string} name Section name, `shipping` or `billing`. + * @param {string} digits The eight digits. + */ + function run(name, digits) { + silence(); + + resolve(digits).then( + function (address) { + if (progress[name].cep === digits) { + apply(name, address); + } + }, + function (error) { + if (progress[name].cep !== digits || error.code === "aborted") { + return; + } + announce(config.messages[error.code] || error.message); + }, + ); + } + + /** + * React to whatever the store now holds for one address. + * + * @param {string} name Section name, `shipping` or `billing`. + * @param {Record<string, any>|undefined} address Address from the store. + */ + function observe(name, address) { + var seen = progress[name]; + + if (!address || String(address.country || "") !== "BR") { + return; + } + + var digits = String(address.postcode || "").replace(/\D+/g, ""); + + if (digits.length !== 8) { + if (seen.cep !== "") { + seen.cep = ""; + window.clearTimeout(seen.timer); + silence(); + } + return; + } + + if (digits === seen.cep) { + return; + } + + seen.cep = digits; + window.clearTimeout(seen.timer); + seen.timer = window.setTimeout(function () { + run(name, digits); + }, config.debounceMs); + } + + data.subscribe(function () { + var store = data.select(CART); + if (!store || typeof store.getCustomerData !== "function") { + return; + } + + var customer = store.getCustomerData(); + if (!customer) { + return; + } + + Object.keys(SECTIONS).forEach(function (name) { + observe(name, customer[SECTIONS[name].address]); + }); + }, CART); +})(); diff --git a/assets/js/cepis-woocommerce-classic.js b/assets/js/cepis-woocommerce-classic.js new file mode 100644 index 0000000..69d8068 --- /dev/null +++ b/assets/js/cepis-woocommerce-classic.js @@ -0,0 +1,167 @@ +/** + * Classic (shortcode) WooCommerce checkout. + * + * The form is rendered by PHP and nothing owns the fields afterwards, so the + * widget is left to do the whole job: debounce, request, write, announce. Most + * of this file is handing it the selectors WooCommerce uses and the settings + * the store owner chose. + * + * Selectors are passed as strings rather than elements on purpose. The widget + * resolves them on every use and delegates its listener from `document`, so a + * theme or one-page-checkout plugin that re-renders the billing form does not + * break the autofill. + * + * @module + */ + +(function () { + var config = window.cepisWooCommerce; + + if (!config || !window.CepisAutofill || !config.fields) { + return; + } + + var DIACRITIC = /[\u0300-\u036f]/g; + + /** + * Compare labels the way a person would: accents and case do not count. + * + * @param {unknown} value Anything printable. + * @returns {string} + */ + function fold(value) { + return String(value == null ? "" : value) + .normalize("NFD") + .replace(DIACRITIC, "") + .trim() + .toLowerCase(); + } + + /** + * Set a value the way a person would, so validation in the page sees it. + * + * @param {HTMLInputElement|HTMLSelectElement} element Field to write. + * @param {string} value Value to put in it. + */ + function write(element, value) { + var descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value"); + + if (descriptor && descriptor.set) { + descriptor.set.call(element, value); + } else { + element.value = value; + } + + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + } + + /** + * Fill the state field. + * + * This one field is kept away from the widget, because it needs the opposite + * of the widget's `overwrite` rule. A street name is worth protecting — the + * shopper may know better than the database. A state is not: the CEP decides + * it. And WooCommerce pre-selects the store's own state on a fresh checkout, + * which `overwrite: "auto"` would read as a choice the shopper made and + * never touch, quietly filing a Rio address under São Paulo. + * + * Nothing is cleared when no option matches, and this runs only after a + * successful lookup, so a CEP that does not exist leaves the field alone. + * + * Focus is put back where it was. WooCommerce runs the state field through + * selectWoo, which takes focus for itself when the value changes, and that + * would otherwise strand the cursor on the state box instead of the house + * number the shopper still has to type. + * + * @param {string} selector CSS selector for the state field. + * @param {string} uf Two-letter state code from the API. + * @param {string} estado Full state name from the API. + */ + function fillState(selector, uf, estado) { + var element = selector ? document.querySelector(selector) : null; + + if (!element || !uf) { + return; + } + + var value = uf; + + if ("SELECT" === element.tagName) { + var wanted = [uf, estado].filter(Boolean).map(fold); + var options = Array.prototype.slice.call(element.options); + var match = null; + + for (var i = 0; i < options.length; i += 1) { + if ( + wanted.indexOf(fold(options[i].value)) !== -1 || + wanted.indexOf(fold(options[i].textContent)) !== -1 + ) { + match = options[i].value; + break; + } + } + + if (null === match) { + return; + } + + value = match; + } + + if (element.value === value) { + return; + } + + var active = document.activeElement; + + write(element, value); + + if (active && active !== document.activeElement && "function" === typeof active.focus) { + active.focus(); + } + } + + /** + * Attach one address section — billing or shipping. + * + * @param {Record<string, string>} map Selectors, keyed by the API's field names. + * @returns {object|null} The widget handle, or null when there is no CEP field. + */ + function section(map) { + if (!map || !map.cep) { + return null; + } + + var options = { + baseUrl: config.baseUrl, + overwrite: config.overwrite, + focusNumero: !!config.focusNumero, + debounceMs: config.debounceMs, + timeoutMs: config.timeoutMs, + messages: config.messages, + onResult: function (address) { + fillState(map.uf, address.uf, address.estado); + }, + }; + + Object.keys(map).forEach(function (field) { + if ("uf" !== field) { + options[field] = map[field]; + } + }); + + return window.CepisAutofill.attach(options); + } + + var handles = []; + + Object.keys(config.fields).forEach(function (name) { + var handle = section(config.fields[name]); + if (handle) { + handles.push(handle); + } + }); + + window.cepisWooCommerceHandles = handles; +})(); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ada24ec --- /dev/null +++ b/bun.lock @@ -0,0 +1,17 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "devDependencies": { + "playwright-core": "^1.62.0", + "prettier": "^3.9.6", + }, + }, + }, + "packages": { + "playwright-core": ["[email protected]", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA=="], + + "prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + } +} diff --git a/cepis-woocommerce.php b/cepis-woocommerce.php new file mode 100644 index 0000000..b9ae502 --- /dev/null +++ b/cepis-woocommerce.php @@ -0,0 +1,100 @@ +<?php +/** + * 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 + * Requires at least: 6.9 + * Tested up to: 7.0 + * Requires PHP: 8.1 + * Requires Plugins: woocommerce + * Author: Murilo Ijanc + * Author URI: https://ijanc.org + * License: ISC + * License URI: https://opensource.org/licenses/ISC + * Text Domain: cepis-woocommerce + * Domain Path: /languages + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +define( 'CEPIS_WOO_VERSION', '0.1.0' ); +define( 'CEPIS_WOO_FILE', __FILE__ ); +define( 'CEPIS_WOO_DIR', plugin_dir_path( __FILE__ ) ); +define( 'CEPIS_WOO_URL', plugin_dir_url( __FILE__ ) ); + +/** + * Autoload the bundled `Cepis\` PHP client. + * + * The library is vendored under `lib/cepis/` rather than installed with + * Composer, because a WordPress site is deployed by copying files and never + * runs `composer install`. Registration is appended, so a Composer autoloader + * already in play on the site keeps precedence and only one copy of the + * classes is ever declared. + * + * @param string $class_name Fully qualified class name being resolved. + */ +function cepis_woo_autoload( string $class_name ): void { + if ( ! str_starts_with( $class_name, 'Cepis\\' ) ) { + return; + } + + $relative = substr( $class_name, 6 ); + if ( str_contains( $relative, '\\' ) || ! preg_match( '/^[A-Za-z0-9_]+$/', $relative ) ) { + return; + } + + $file = CEPIS_WOO_DIR . 'lib/cepis/' . $relative . '.php'; + if ( is_readable( $file ) ) { + require_once $file; + } +} + +spl_autoload_register( 'cepis_woo_autoload' ); + +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-options.php'; +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-messages.php'; +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-token.php'; +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-transport.php'; +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-lookup.php'; +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-rest.php'; +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-assets.php'; +require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-admin.php'; + +/** + * Boot the plugin once WordPress has loaded its own translations. + * + * Every hook registered here fires at or after `init`, which is what keeps + * translation calls out of the `_load_textdomain_just_in_time` notice. + */ +function cepis_woo_init(): void { + load_plugin_textdomain( 'cepis-woocommerce', false, dirname( plugin_basename( CEPIS_WOO_FILE ) ) . '/languages' ); + + Cepis_Woo_Assets::init(); + Cepis_Woo_Rest::init(); + + if ( is_admin() ) { + Cepis_Woo_Admin::init(); + } +} + +add_action( 'init', 'cepis_woo_init' ); + +/** + * Declare compatibility with WooCommerce High-Performance Order Storage. + * + * The plugin never reads or writes an order, so the declaration is honest and + * keeps the store owner from seeing an "incompatible plugin" warning. + */ +function cepis_woo_declare_hpos(): void { + if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', CEPIS_WOO_FILE, true ); + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', CEPIS_WOO_FILE, true ); + } +} + +add_action( 'before_woocommerce_init', 'cepis_woo_declare_hpos' ); diff --git a/includes/class-cepis-woo-admin.php b/includes/class-cepis-woo-admin.php new file mode 100644 index 0000000..efb1959 --- /dev/null +++ b/includes/class-cepis-woo-admin.php @@ -0,0 +1,74 @@ +<?php +/** + * Admin-side wiring. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * Puts the settings screen where a store owner will look for it, and links to + * it from the plugins list. + */ +final class Cepis_Woo_Admin { + + /** + * Hook everything the admin needs. + */ + public static function init(): void { + add_filter( 'woocommerce_get_settings_pages', array( self::class, 'register_settings_page' ) ); + add_filter( 'plugin_action_links_' . plugin_basename( CEPIS_WOO_FILE ), array( self::class, 'action_links' ) ); + } + + /** + * Add the settings tab to WooCommerce's own settings screen. + * + * The class is required here rather than at load time because it extends + * `WC_Settings_Page`, which does not exist until WooCommerce has booted its + * admin. + * + * @param array<int, mixed> $pages Settings pages WooCommerce has collected. + * + * @return array<int, mixed> + */ + public static function register_settings_page( array $pages ): array { + if ( ! class_exists( 'WC_Settings_Page' ) ) { + return $pages; + } + + require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-settings.php'; + + $pages[] = new Cepis_Woo_Settings(); + + return $pages; + } + + /** + * Add a "Settings" link to the plugin's row. + * + * @param array<int|string, string> $links Existing action links. + * + * @return array<int|string, string> + */ + public static function action_links( array $links ): array { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return $links; + } + + $url = admin_url( 'admin.php?page=wc-settings&tab=cepis' ); + + array_unshift( + $links, + sprintf( + '<a href="%s">%s</a>', + esc_url( $url ), + esc_html__( 'Settings', 'cepis-woocommerce' ) + ) + ); + + return $links; + } +} diff --git a/includes/class-cepis-woo-assets.php b/includes/class-cepis-woo-assets.php new file mode 100644 index 0000000..def6fe2 --- /dev/null +++ b/includes/class-cepis-woo-assets.php @@ -0,0 +1,183 @@ +<?php +/** + * What gets loaded on the checkout page, and with what configuration. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * Detects which of WooCommerce's two checkouts is on screen and loads the + * matching integration. + * + * They are not variations of one another. The classic checkout is a server + * rendered form, so the widget can own the fields directly. The Cart/Checkout + * Blocks are React, the fields are controlled inputs, and writing to them from + * outside is at best ignored on the next render — so there the plugin never + * touches the DOM and drives the `wc/store/cart` data store instead. + */ +final class Cepis_Woo_Assets { + + /** + * Script handle for the vendored widget. + */ + private const WIDGET = 'cepis-autofill'; + + /** + * Hook the enqueue. + */ + public static function init(): void { + add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue' ) ); + } + + /** + * Load the widget plus whichever integration this checkout needs. + */ + public static function enqueue(): void { + $checkout = self::checkout_type(); + + if ( '' === $checkout || ! Cepis_Woo_Options::enabled() ) { + return; + } + + wp_register_script( + self::WIDGET, + CEPIS_WOO_URL . 'assets/js/cepis-autofill.min.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' ); + + wp_enqueue_script( + $handle, + CEPIS_WOO_URL . 'assets/js/cepis-woocommerce-' . $checkout . '.js', + $dependencies, + CEPIS_WOO_VERSION, + true + ); + + wp_add_inline_script( + $handle, + 'window.cepisWooCommerce = ' . wp_json_encode( self::config( $checkout ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';', + 'before' + ); + } + + /** + * Which checkout is being rendered. + * + * @return string `blocks`, `classic`, or an empty string when this is not a + * checkout the plugin should touch. + */ + private static function checkout_type(): string { + if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) { + return ''; + } + + if ( function_exists( 'is_order_received_page' ) && is_order_received_page() ) { + return ''; + } + + $post = get_post(); + + return ( $post instanceof WP_Post && has_block( 'woocommerce/checkout', $post ) ) ? 'blocks' : 'classic'; + } + + /** + * Everything the browser needs, in one object. + * + * @param string $checkout Either `blocks` or `classic`. + * + * @return array<string, mixed> + */ + private static function config( string $checkout ): array { + return array( + 'baseUrl' => 'proxy' === Cepis_Woo_Options::mode() + ? Cepis_Woo_Rest::base_url() + : Cepis_Woo_Options::base_url(), + 'overwrite' => Cepis_Woo_Options::overwrite(), + 'focusNumero' => Cepis_Woo_Options::focus_number(), + 'debounceMs' => 250, + 'timeoutMs' => 8000, + 'messages' => Cepis_Woo_Messages::all(), + 'fields' => 'blocks' === $checkout ? self::blocks_fields() : self::classic_fields(), + ); + } + + /** + * CSS selectors for the classic checkout, per address section. + * + * `billing_number` and `billing_neighborhood` are not WooCommerce fields — + * they come from the "Brazilian Market on WooCommerce" plugin, which is the + * de-facto way a Brazilian store gets a house-number and a neighbourhood + * box. A selector that matches nothing is skipped, so listing them costs + * nothing on a store that does not have them. + * + * There is deliberately no fallback onto `address_2` for the neighbourhood: + * in WooCommerce that field means "apartment, suite, unit", and putting a + * neighbourhood there produces a wrong address on the shipping label. + * + * @return array<string, array<string, string>> + */ + private static function classic_fields(): array { + $fields = array(); + + foreach ( array( 'billing', 'shipping' ) as $section ) { + $fields[ $section ] = array( + 'cep' => '#' . $section . '_postcode', + 'logradouro' => '#' . $section . '_address_1', + 'bairro' => '#' . $section . '_neighborhood', + 'localidade' => '#' . $section . '_city', + 'uf' => '#' . $section . '_state', + 'numero' => '#' . $section . '_number', + ); + } + + /** + * Filters the field selectors used on the classic checkout. + * + * Keys are the API's own field names; values are CSS selectors. A theme + * or one-page-checkout plugin that renames the fields hooks in here. + * + * @param array<string, array<string, string>> $fields Selectors per address section. + */ + return (array) apply_filters( 'cepis_woocommerce_classic_fields', $fields ); + } + + /** + * Store API address keys for the Cart/Checkout Blocks. + * + * The block checkout has no house-number and no neighbourhood field, and no + * plugin currently adds one: "Brazilian Market on WooCommerce" only extends + * the classic form. A store that registers its own with + * `woocommerce_register_additional_checkout_field()` can map it here, using + * the namespaced key the Store API exposes it under. + * + * @return array<string, string> + */ + private static function blocks_fields(): array { + $fields = array( + 'logradouro' => 'address_1', + 'localidade' => 'city', + 'uf' => 'state', + ); + + /** + * Filters the Store API address keys written on the block checkout. + * + * Keys are the API's own field names; values are keys of the address + * object in the `wc/store/cart` data store. + * + * @param array<string, string> $fields Address keys per API field. + */ + return (array) apply_filters( 'cepis_woocommerce_blocks_fields', $fields ); + } +} diff --git a/includes/class-cepis-woo-lookup.php b/includes/class-cepis-woo-lookup.php new file mode 100644 index 0000000..47e3f16 --- /dev/null +++ b/includes/class-cepis-woo-lookup.php @@ -0,0 +1,170 @@ +<?php +/** + * Server-side CEP lookups, cached. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * Resolves a CEP with the bundled `Cepis\Client` and remembers the answer in a + * transient. + * + * Only the proxy mode goes through here. In the default direct mode the + * browser talks to cep.is itself and no PHP runs at all. + */ +final class Cepis_Woo_Lookup { + + /** + * Prefix for the transient holding one CEP's answer. + */ + private const CACHE_PREFIX = 'cepis_woo_'; + + /** + * How long a resolved address is kept. + * + * The DNE database behind the API is regenerated fortnightly, so a week is + * half a refresh cycle: an address can be at most one release behind, while + * a CEP a store sees repeatedly still costs one outbound request a week. + */ + private const HIT_TTL = WEEK_IN_SECONDS; + + /** + * How long a "this CEP does not exist" is kept. + * + * Much shorter than a hit, and for a different reason: a CEP absent today + * can appear in the next fortnightly base, and telling a shopper their + * brand-new street does not exist for two weeks is worse than making the + * request again tomorrow. + */ + private const MISS_TTL = DAY_IN_SECONDS; + + /** + * Look a CEP up, from cache when possible. + * + * Never throws: every outcome is described by the returned status and body, + * in exactly the shapes the public API uses, so the browser sees the same + * thing whichever mode the store is in. + * + * @param string $cep Eight digits, already validated by the caller. + * + * @return array{status:int, body:array<string, mixed>, retry_after:?int} + */ + public static function resolve( string $cep ): array { + $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, + ); + } + + try { + $address = self::client()->lookup( $cep ); + } catch ( Cepis\CepNotFoundException ) { + set_transient( self::CACHE_PREFIX . $cep, array( 'miss' => true ), self::MISS_TTL ); + + return self::not_found(); + } catch ( Cepis\InvalidCepException ) { + return array( + 'status' => 400, + 'body' => array( + 'erro' => 'cep_invalido', + 'mensagem' => 'CEP deve ter 8 dígitos', + ), + 'retry_after' => null, + ); + } catch ( Cepis\RateLimitedException $e ) { + return array( + 'status' => 429, + 'body' => array( + 'erro' => 'limite', + 'mensagem' => 'Muitas consultas em pouco tempo', + ), + 'retry_after' => null === $e->retryAfter ? null : (int) $e->retryAfter, + ); + } catch ( Cepis\CepisException ) { + return array( + 'status' => 502, + 'body' => array( + 'erro' => 'interno', + 'mensagem' => 'falha interna', + ), + 'retry_after' => null, + ); + } + + $body = $address->raw; + set_transient( self::CACHE_PREFIX . $cep, $body, self::HIT_TTL ); + + return array( + 'status' => 200, + 'body' => $body, + 'retry_after' => null, + ); + } + + /** + * Drop every cached answer. + * + * Used by the settings screen when the API root changes, since answers from + * one instance say nothing about another. + */ + public static function flush(): void { + global $wpdb; + + $like = $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%'; + + $names = $wpdb->get_col( + $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like ) + ); + + foreach ( (array) $names as $name ) { + delete_transient( substr( (string) $name, strlen( '_transient_' ) ) ); + } + } + + /** + * The client, built once per request. + */ + private static function client(): Cepis\Client { + static $client = null; + + if ( null === $client ) { + $client = new Cepis\Client( + baseUrl: Cepis_Woo_Options::base_url(), + timeout: 5.0, + maxAttempts: 2, + totalTimeout: 8.0, + userAgent: 'cepis-woocommerce/' . CEPIS_WOO_VERSION . ' (+https://cep.is)', + transport: new Cepis_Woo_Transport(), + ); + } + + return $client; + } + + /** + * The API's own 404 body, reproduced so the browser cannot tell the two + * modes apart. + * + * @return array{status:int, body:array<string, mixed>, retry_after:?int} + */ + private static function not_found(): array { + return array( + 'status' => 404, + 'body' => array( + 'erro' => 'cep_nao_encontrado', + 'mensagem' => 'CEP não existe na base do DNE', + ), + 'retry_after' => null, + ); + } +} diff --git a/includes/class-cepis-woo-messages.php b/includes/class-cepis-woo-messages.php new file mode 100644 index 0000000..201fe4e --- /dev/null +++ b/includes/class-cepis-woo-messages.php @@ -0,0 +1,60 @@ +<?php +/** + * The strings the shopper sees while a CEP is looked up. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * The widget ships its own Portuguese messages; these replace them so that a + * store running in another locale gets the checkout in that locale, and so a + * store owner can reword them with the usual translation tooling. + * + * Keys are the widget's status kinds and error codes. Anything not listed + * keeps the widget's own wording. + */ +final class Cepis_Woo_Messages { + + /** + * Build the message table. + * + * Must be called at or after `init`: it is the first thing in the plugin + * that translates. + * + * @return array<string, string> Message per status kind or error code. + */ + public static function all(): array { + $messages = array( + 'idle' => '', + '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' ), + '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' ), + 'network' => __( 'Could not look the CEP up. Check your connection and try again.', 'cepis-woocommerce' ), + 'timeout' => __( 'The CEP lookup took too long. Try again or fill the address in by hand.', 'cepis-woocommerce' ), + 'invalid_response' => __( 'Unexpected answer from the CEP service. Fill the address in by hand.', 'cepis-woocommerce' ), + ); + + /** + * Filters the messages shown at the checkout while a CEP is resolved. + * + * @param array<string, string> $messages Message per status kind or error code. + */ + $messages = (array) apply_filters( 'cepis_woocommerce_messages', $messages ); + + $clean = array(); + foreach ( $messages as $key => $message ) { + if ( is_string( $key ) && is_scalar( $message ) ) { + $clean[ sanitize_key( $key ) ] = wp_strip_all_tags( (string) $message ); + } + } + + return $clean; + } +} diff --git a/includes/class-cepis-woo-options.php b/includes/class-cepis-woo-options.php new file mode 100644 index 0000000..7a4a0b0 --- /dev/null +++ b/includes/class-cepis-woo-options.php @@ -0,0 +1,105 @@ +<?php +/** + * Stored settings and their defaults. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * Reads the plugin's options, normalising whatever is in the database into the + * small set of values the rest of the plugin is allowed to see. + * + * Every getter returns a value from a fixed set: an option edited by hand in + * the database, or left over from an older release, can never reach the + * browser or the HTTP client as-is. + */ +final class Cepis_Woo_Options { + + /** + * Option names, so that the settings screen and `uninstall.php` cannot + * drift apart. + */ + public const NAMES = array( + 'cepis_woo_enabled', + 'cepis_woo_mode', + 'cepis_woo_overwrite', + 'cepis_woo_focus_number', + 'cepis_woo_base_url', + ); + + /** + * The public API, and the default for the `base_url` setting. + */ + public const DEFAULT_BASE_URL = 'https://cep.is'; + + /** + * Whether the autofill should run at all. + */ + public static function enabled(): bool { + return 'yes' === get_option( 'cepis_woo_enabled', 'yes' ); + } + + /** + * Where the browser sends its lookups. + * + * `direct` has the browser call cep.is itself: one cross-origin GET against + * an edge cache, with no PHP in the path. `proxy` routes the lookup through + * this site's REST API instead, for stores whose Content-Security-Policy + * forbids external XHR or whose owner wants the server-side cache. + * + * @return string Either `direct` or `proxy`. + */ + public static function mode(): string { + return 'proxy' === get_option( 'cepis_woo_mode', 'direct' ) ? 'proxy' : 'direct'; + } + + /** + * What to do with a checkout field that already holds a value. + * + * @return bool|string `"auto"`, `true` or `false`, in the shape the widget + * expects for its own `overwrite` option. + */ + public static function overwrite(): bool|string { + $value = (string) get_option( 'cepis_woo_overwrite', 'auto' ); + + if ( 'always' === $value ) { + return true; + } + if ( 'never' === $value ) { + return false; + } + + return 'auto'; + } + + /** + * Whether to move the cursor to the house-number field after a hit. + * + * Only ever does anything on the classic checkout, and only when a + * house-number field exists. + */ + public static function focus_number(): bool { + return 'yes' === get_option( 'cepis_woo_focus_number', 'yes' ); + } + + /** + * Root of the API to query, without a trailing slash. + * + * Overridable so a store can point at its own instance, and so the test + * suite can point at a fake one. + */ + public static function base_url(): string { + $value = (string) get_option( 'cepis_woo_base_url', self::DEFAULT_BASE_URL ); + $value = untrailingslashit( trim( $value ) ); + + if ( '' === $value || ! preg_match( '#^https?://#i', $value ) ) { + return self::DEFAULT_BASE_URL; + } + + return $value; + } +} diff --git a/includes/class-cepis-woo-rest.php b/includes/class-cepis-woo-rest.php new file mode 100644 index 0000000..561ca7e --- /dev/null +++ b/includes/class-cepis-woo-rest.php @@ -0,0 +1,120 @@ +<?php +/** + * The optional server-side proxy route. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * Exposes `GET /wp-json/cepis/v1/lookup/{token}/{cep}`, which answers exactly + * what cep.is answers. + * + * The route is only registered when the store has turned proxy mode on. It is + * off by default because the browser can reach cep.is directly in about twenty + * milliseconds against a Cloudflare edge cache, and putting a WordPress + * bootstrap in front of that — on the checkout page, once per settled CEP — + * spends latency where a store can least afford it. Turn it on when a + * Content-Security-Policy forbids external XHR, or to get the server-side + * cache in {@see Cepis_Woo_Lookup}. + * + * The token is a path segment rather than a header because the widget's + * request is a plain cross-origin-safe GET with no custom headers. + * {@see Cepis_Woo_Token} explains why it is not a session-bound nonce. + */ +final class Cepis_Woo_Rest { + + /** + * REST namespace for the proxy. + */ + public const NAMESPACE_V1 = 'cepis/v1'; + + /** + * Register the route, when the store asked for it. + */ + public static function init(): void { + if ( ! Cepis_Woo_Options::enabled() || 'proxy' !== Cepis_Woo_Options::mode() ) { + return; + } + + add_action( 'rest_api_init', array( self::class, 'register' ) ); + } + + /** + * Declare the route. + */ + public static function register(): void { + register_rest_route( + self::NAMESPACE_V1, + '/lookup/(?P<token>[A-Za-z0-9]{1,64})/(?P<cep>[0-9]{8})', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'lookup' ), + 'permission_callback' => array( self::class, 'permitted' ), + 'args' => array( + 'token' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ), + 'cep' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => static fn( $value ): bool => 1 === preg_match( '/^[0-9]{8}$/', (string) $value ), + ), + ), + ) + ); + } + + /** + * Reject anything that does not carry a live token. + * + * @param WP_REST_Request $request Incoming request. + * + * @return true|WP_Error True when the token checks out. + */ + public static function permitted( WP_REST_Request $request ): bool|WP_Error { + if ( Cepis_Woo_Token::verify( (string) $request['token'] ) ) { + return true; + } + + return new WP_Error( + 'cepis_invalid_token', + __( 'Lookup token missing, invalid or expired. Reload the page.', 'cepis-woocommerce' ), + array( 'status' => 403 ) + ); + } + + /** + * Answer one lookup. + * + * @param WP_REST_Request $request Incoming request. + */ + public static function lookup( WP_REST_Request $request ): WP_REST_Response { + $result = Cepis_Woo_Lookup::resolve( (string) $request['cep'] ); + + $response = new WP_REST_Response( $result['body'], $result['status'] ); + $response->header( 'Cache-Control', 'no-store' ); + + if ( null !== $result['retry_after'] ) { + $response->header( 'Retry-After', (string) $result['retry_after'] ); + } + + return $response; + } + + /** + * The API root to hand the browser, with a live token already in it. + * + * Shaped so that appending `/{cep}` to it reaches this route, which is what + * the widget does with its `baseUrl`. + */ + public static function base_url(): string { + return untrailingslashit( rest_url( self::NAMESPACE_V1 . '/lookup/' . Cepis_Woo_Token::create() ) ); + } +} diff --git a/includes/class-cepis-woo-settings.php b/includes/class-cepis-woo-settings.php new file mode 100644 index 0000000..3fa73a1 --- /dev/null +++ b/includes/class-cepis-woo-settings.php @@ -0,0 +1,135 @@ +<?php +/** + * The settings tab under WooCommerce. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * WooCommerce → Settings → CEP autofill. + * + * Extending `WC_Settings_Page` rather than rolling an options screen means the + * capability check, the `woocommerce-settings` nonce and the per-type + * sanitisation of every field come from WooCommerce, which is the code most + * likely to stay correct as WordPress changes. What is added on top is a + * second, explicit check on save and a stricter sanitiser for the one free-text + * field. + */ +final class Cepis_Woo_Settings extends WC_Settings_Page { + + /** + * Identify the tab. + */ + public function __construct() { + $this->id = 'cepis'; + $this->label = __( 'CEP autofill', 'cepis-woocommerce' ); + + parent::__construct(); + + add_filter( 'woocommerce_admin_settings_sanitize_option_cepis_woo_base_url', array( $this, 'sanitize_base_url' ) ); + } + + /** + * The fields on the tab. + * + * @return array<int, array<string, mixed>> + */ + public function get_settings_for_default_section(): array { + return array( + array( + 'title' => __( 'CEP autofill', 'cepis-woocommerce' ), + 'type' => 'title', + 'desc' => __( 'Fills the checkout address from the CEP the shopper types, using official Correios data (DNE) from cep.is.', 'cepis-woocommerce' ), + 'id' => 'cepis_woo_section', + ), + array( + 'title' => __( 'Autofill', 'cepis-woocommerce' ), + 'desc' => __( 'Look the address up while the shopper types the CEP', 'cepis-woocommerce' ), + 'id' => 'cepis_woo_enabled', + 'type' => 'checkbox', + 'default' => 'yes', + ), + array( + 'title' => __( 'Lookups go through', 'cepis-woocommerce' ), + 'id' => 'cepis_woo_mode', + 'type' => 'radio', + 'default' => 'direct', + 'options' => array( + 'direct' => __( 'The shopper’s browser, straight to cep.is (recommended)', 'cepis-woocommerce' ), + 'proxy' => __( 'This site, through the WordPress REST API', 'cepis-woocommerce' ), + ), + 'desc_tip' => __( 'A direct lookup hits an edge cache and costs this store nothing. Route it through the site when a Content-Security-Policy forbids external requests from the page, or to cache answers server-side.', 'cepis-woocommerce' ), + ), + array( + 'title' => __( 'Fields already filled in', 'cepis-woocommerce' ), + 'id' => 'cepis_woo_overwrite', + 'type' => 'select', + 'class' => 'wc-enhanced-select', + 'default' => 'auto', + 'options' => array( + 'auto' => __( 'Keep what the shopper typed, replace what this plugin wrote', 'cepis-woocommerce' ), + 'never' => __( 'Only ever fill empty fields', 'cepis-woocommerce' ), + 'always' => __( 'Always overwrite', 'cepis-woocommerce' ), + ), + 'desc_tip' => __( 'A shopper who corrects the street by hand should not see the correction undone by the next lookup.', 'cepis-woocommerce' ), + ), + array( + 'title' => __( 'House number', 'cepis-woocommerce' ), + 'desc' => __( 'Move the cursor to the house-number field after a successful lookup', 'cepis-woocommerce' ), + 'id' => 'cepis_woo_focus_number', + 'type' => 'checkbox', + 'default' => 'yes', + 'desc_tip' => __( 'Classic checkout only, and only when the store has a house-number field.', 'cepis-woocommerce' ), + ), + array( + 'title' => __( 'API address', 'cepis-woocommerce' ), + 'id' => 'cepis_woo_base_url', + 'type' => 'url', + 'default' => Cepis_Woo_Options::DEFAULT_BASE_URL, + 'desc_tip' => __( 'Leave as it is unless you run your own instance of the API.', 'cepis-woocommerce' ), + ), + array( + 'type' => 'sectionend', + 'id' => 'cepis_woo_section', + ), + ); + } + + /** + * Save the tab. + * + * WooCommerce already gates this screen, but the check is repeated here so + * that the guarantee does not depend on a menu registration somewhere else + * staying correct. + */ + public function save(): void { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'You are not allowed to change these settings.', 'cepis-woocommerce' ), 403 ); + } + + check_admin_referer( 'woocommerce-settings' ); + + $previous = Cepis_Woo_Options::base_url(); + + parent::save(); + + if ( Cepis_Woo_Options::base_url() !== $previous ) { + Cepis_Woo_Lookup::flush(); + } + } + + /** + * Keep the API address to an absolute http(s) URL. + * + * @param mixed $value Whatever the form posted. + */ + public function sanitize_base_url( mixed $value ): string { + $url = esc_url_raw( trim( (string) $value ), array( 'http', 'https' ) ); + + return '' === $url ? Cepis_Woo_Options::DEFAULT_BASE_URL : untrailingslashit( $url ); + } +} diff --git a/includes/class-cepis-woo-token.php b/includes/class-cepis-woo-token.php new file mode 100644 index 0000000..1d1b9f4 --- /dev/null +++ b/includes/class-cepis-woo-token.php @@ -0,0 +1,76 @@ +<?php +/** + * The token that guards the server-side proxy route. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * A short-lived nonce for the proxy route, derived the way WordPress derives + * its own but without binding to a login session. + * + * The bundled widget fetches with `credentials: "omit"` — deliberately, since + * a CEP lookup has no business carrying the shopper's cookies. The proxy + * therefore never sees an authentication cookie and every request reaches it + * anonymous, whether or not the shopper is logged in. A cookie-bound + * {@see wp_create_nonce()} would embed a session token in the page that the + * request could not present, so it would verify for guests and fail for + * everyone with an account: the worst possible failure mode, invisible in + * testing and broken in production. + * + * What the token has to defend against is the only threat this endpoint + * actually has — a third party pointing their own site at this store's proxy + * and using it as a free CEP gateway. A value tied to the site's salts and to + * {@see wp_nonce_tick()} does exactly that: it cannot be produced without the + * salts, and it stops working within a day. + * + * Same lifetime as a WordPress nonce: the current tick and the one before it + * are both accepted, so a checkout page held open overnight keeps working. + */ +final class Cepis_Woo_Token { + + /** + * Namespaces the hash, so a token cannot be replayed against anything else + * on the site that hashes with the `nonce` salt. + */ + private const ACTION = 'cepis_woo_proxy_lookup'; + + /** + * Mint the token to embed in the checkout page. + */ + public static function create(): string { + return self::hash( wp_nonce_tick( self::ACTION ) ); + } + + /** + * Check a token presented by the browser. + * + * Compared with {@see hash_equals()} so that a wrong token takes the same + * time to reject as a right one. + * + * @param string $token Token as it arrived in the request. + */ + public static function verify( string $token ): bool { + if ( '' === $token ) { + return false; + } + + $tick = wp_nonce_tick( self::ACTION ); + + return hash_equals( self::hash( $tick ), $token ) + || hash_equals( self::hash( $tick - 1 ), $token ); + } + + /** + * Derive the token for one tick. + * + * @param float $tick Nonce tick to derive from. + */ + private static function hash( float $tick ): string { + return substr( wp_hash( $tick . '|' . self::ACTION, 'nonce' ), -12, 10 ); + } +} diff --git a/includes/class-cepis-woo-transport.php b/includes/class-cepis-woo-transport.php new file mode 100644 index 0000000..888be54 --- /dev/null +++ b/includes/class-cepis-woo-transport.php @@ -0,0 +1,58 @@ +<?php +/** + * The bundled PHP client, taught to speak through the WordPress HTTP API. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'ABSPATH' ) || exit; + +/** + * A `Cepis\Transport` over {@see wp_remote_get()}. + * + * The library defaults to talking to curl directly, which is the wrong thing + * to do inside WordPress: a site's proxy configuration, its + * `http_request_args` filters, its request timeouts and everything a host adds + * on top of the HTTP API would all be bypassed. Routing through + * {@see wp_remote_get()} keeps this plugin a well-behaved guest. + * + * Exactly one request per call, no retrying — retries, backoff and + * `Retry-After` stay the client's job. + */ +final class Cepis_Woo_Transport implements Cepis\Transport { + + /** + * Perform a single GET. + * + * @param string $url Absolute URL, query string included. + * @param array<string, string> $headers Request headers as name => value. + * @param float $timeout Seconds to allow for this attempt. + * + * @throws Cepis\NetworkException The request never completed. + */ + public function get( string $url, array $headers, float $timeout ): Cepis\Response { + $response = wp_remote_get( + $url, + array( + 'headers' => $headers, + 'timeout' => $timeout, + 'redirection' => 3, + 'sslverify' => true, + ) + ); + + if ( is_wp_error( $response ) ) { + throw new Cepis\NetworkException( esc_html( $response->get_error_message() ) ); + } + + $response_headers = wp_remote_retrieve_headers( $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 ) + ); + } +} diff --git a/languages/cepis-woocommerce-pt_BR.mo b/languages/cepis-woocommerce-pt_BR.mo Binary files differnew file mode 100644 index 0000000..961e3e8 --- /dev/null +++ b/languages/cepis-woocommerce-pt_BR.mo diff --git a/languages/cepis-woocommerce-pt_BR.po b/languages/cepis-woocommerce-pt_BR.po new file mode 100644 index 0000000..1169624 --- /dev/null +++ b/languages/cepis-woocommerce-pt_BR.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: CEP Autofill for WooCommerce 0.1.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" +"Language-Team: Portugues do Brasil\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Looking the address up…" +msgstr "Buscando endereço…" + +msgid "Address found." +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 "Invalid CEP. Type all 8 digits." +msgstr "CEP inválido. Digite os 8 dígitos." + +msgid "Too many lookups in a short time. Wait a few seconds and try again." +msgstr "Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo." + +msgid "The CEP service is unavailable. Fill the address in by hand." +msgstr "O serviço de CEP está indisponível. Preencha o endereço manualmente." + +msgid "Could not look the CEP up. Check your connection and try again." +msgstr "Não foi possível consultar o CEP. Verifique sua conexão e tente de novo." + +msgid "The CEP lookup took too long. Try again or fill the address in by hand." +msgstr "A consulta do CEP demorou demais. Tente de novo ou preencha manualmente." + +msgid "Unexpected answer from the CEP service. Fill the address in by hand." +msgstr "Resposta inesperada do serviço de CEP. Preencha o endereço manualmente." + +msgid "Lookup token missing, invalid or expired. Reload the page." +msgstr "Token de consulta ausente, inválido ou expirado. Recarregue a página." + +msgid "CEP autofill" +msgstr "Preenchimento por CEP" + +msgid "Settings" +msgstr "Configurações" + +msgid "" +"Fills the checkout address from the CEP the shopper types, using official " +"Correios data (DNE) from cep.is." +msgstr "" +"Preenche o endereço do checkout a partir do CEP digitado pelo cliente, com " +"dados oficiais dos Correios (DNE) via cep.is." + +msgid "Autofill" +msgstr "Preenchimento automático" + +msgid "Look the address up while the shopper types the CEP" +msgstr "Consultar o endereço enquanto o cliente digita o CEP" + +msgid "Lookups go through" +msgstr "As consultas passam por" + +msgid "The shopper’s browser, straight to cep.is (recommended)" +msgstr "O navegador do cliente, direto no cep.is (recomendado)" + +msgid "This site, through the WordPress REST API" +msgstr "Este site, pela API REST do WordPress" + +msgid "" +"A direct lookup hits an edge cache and costs this store nothing. Route it " +"through the site when a Content-Security-Policy forbids external requests " +"from the page, or to cache answers server-side." +msgstr "" +"A consulta direta cai em cache de borda e não custa nada para a loja. Passe " +"pelo site quando uma Content-Security-Policy proibir requisições externas na " +"página, ou para guardar as respostas em cache no servidor." + +msgid "Fields already filled in" +msgstr "Campos já preenchidos" + +msgid "Keep what the shopper typed, replace what this plugin wrote" +msgstr "Manter o que o cliente digitou, substituir o que este plugin escreveu" + +msgid "Only ever fill empty fields" +msgstr "Preencher apenas campos vazios" + +msgid "Always overwrite" +msgstr "Sempre sobrescrever" + +msgid "" +"A shopper who corrects the street by hand should not see the correction " +"undone by the next lookup." +msgstr "" +"Quem corrige o logradouro na mão não deve perder a correção na consulta " +"seguinte." + +msgid "House number" +msgstr "Número" + +msgid "Move the cursor to the house-number field after a successful lookup" +msgstr "Levar o cursor para o campo de número após uma consulta bem-sucedida" + +msgid "Classic checkout only, and only when the store has a house-number field." +msgstr "Só no checkout clássico, e só quando a loja tem campo de número." + +msgid "API address" +msgstr "Endereço da API" + +msgid "Leave as it is unless you run your own instance of the API." +msgstr "Deixe como está, a menos que você rode sua própria instância da API." + +msgid "You are not allowed to change these settings." +msgstr "Você não tem permissão para alterar estas configurações." diff --git a/lib/cepis/Address.php b/lib/cepis/Address.php new file mode 100644 index 0000000..8b970aa --- /dev/null +++ b/lib/cepis/Address.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * A CEP and everything the database knows about it. + * + * **Property names are the API's own JSON keys, in Portuguese** — + * `logradouro`, `bairro`, `localidade`. Everything else in this library is + * English; the data fields are the one deliberate exception, so that what you + * read in the JSON at https://cep.is/01001000 is exactly what you read on the + * object, with no translation layer and no field-by-field mapping to keep in + * your head. The values are Portuguese either way — they come from the + * Correios database. + * + * Fields the database does not have for that CEP come back as empty strings, + * never `null` — that is what the API returns. + * + * {@see Address::$geo} is the exception: it is `null` when there is no + * coordinate. There the difference is real, because the API **omits** the + * whole field in that case (about 43% of CEPs) rather than sending null. + * + * {@see Address::$raw} holds the response exactly as it arrived, + * uninterpreted, so a field the API grows before this library is updated is + * not lost. + */ +final class Address +{ + /** + * The JSON keys mapped onto properties, in the API's own order. + */ + public const FIELDS = [ + 'cep', 'logradouro', 'complemento', 'unidade', 'bairro', 'localidade', + 'uf', 'estado', 'regiao', 'ibge', 'gia', 'ddd', 'siafi', + ]; + + /** + * @param array<string, mixed> $raw + */ + public function __construct( + public readonly string $cep = '', + public readonly string $logradouro = '', + public readonly string $complemento = '', + public readonly string $unidade = '', + public readonly string $bairro = '', + public readonly string $localidade = '', + public readonly string $uf = '', + public readonly string $estado = '', + public readonly string $regiao = '', + public readonly string $ibge = '', + public readonly string $gia = '', + public readonly string $ddd = '', + public readonly string $siafi = '', + public readonly ?Geo $geo = null, + public readonly array $raw = [], + ) { + } + + /** + * Build from the decoded response. + * + * Unknown fields are ignored during construction but preserved in + * {@see Address::$raw}. A new field in the API therefore cannot break this + * library. + * + * @param array<string, mixed> $data + */ + public static function fromArray(array $data): self + { + $values = []; + foreach (self::FIELDS as $name) { + $value = $data[$name] ?? ''; + $values[$name] = is_scalar($value) ? (string) $value : ''; + } + + return new self( + cep: $values['cep'], + logradouro: $values['logradouro'], + complemento: $values['complemento'], + unidade: $values['unidade'], + bairro: $values['bairro'], + localidade: $values['localidade'], + uf: $values['uf'], + estado: $values['estado'], + regiao: $values['regiao'], + ibge: $values['ibge'], + gia: $values['gia'], + ddd: $values['ddd'], + siafi: $values['siafi'], + geo: Geo::fromArray($data['geo'] ?? null), + raw: $data, + ); + } + + /** + * Read any field of the raw response, including one the API grew after + * this release, or `null` when it is absent. + */ + public function field(string $name): ?string + { + $value = $this->raw[$name] ?? null; + + return is_scalar($value) ? (string) $value : null; + } + + /** + * The CEP with its mask: `01001-000`. + * + * The API always returns it unmasked; formatting is presentation, and it + * lives here so it does not become a repeated `sprintf` in every project + * that uses this. + */ + public function formattedCep(): string + { + return substr($this->cep, 0, 5) . '-' . substr($this->cep, 5, 3); + } + + /** + * @return array<string, mixed> + */ + public function toArray(): array + { + $out = []; + foreach (self::FIELDS as $name) { + $out[$name] = $this->{$name}; + } + $out['geo'] = $this->geo?->toArray(); + + return $out; + } +} diff --git a/lib/cepis/CepNotFoundException.php b/lib/cepis/CepNotFoundException.php new file mode 100644 index 0000000..c64de8b --- /dev/null +++ b/lib/cepis/CepNotFoundException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The CEP is well-formed but does not exist in the DNE database. + * + * Extends `\OutOfBoundsException`, the SPL type for a value that is not a + * valid key: looking a CEP up is conceptually a key lookup. + */ +final class CepNotFoundException extends \OutOfBoundsException implements CepisException +{ +} diff --git a/lib/cepis/CepisException.php b/lib/cepis/CepisException.php new file mode 100644 index 0000000..efd1a86 --- /dev/null +++ b/lib/cepis/CepisException.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * Implemented by every exception this library throws. Catch it to handle any + * failure with one handler. + * + * It is an interface rather than a base class so that each concrete exception + * can also extend the SPL type a PHP developer would already be catching: + * {@see InvalidCepException} is an `\InvalidArgumentException`, so code that + * already validates input keeps working without knowing this library exists, + * and {@see CepNotFoundException} is an `\OutOfBoundsException`, because + * looking a CEP up is conceptually a key lookup. PHP has single inheritance; + * a marker interface is the only way to have both. + * + * Below this interface the split is by **the decision the caller has to + * make**, not by the HTTP status that caused it: + * + * - {@see InvalidCepException} and {@see CepNotFoundException} are problems + * with the *request*. Retrying is pointless — the same CEP will fail again. + * - {@see NetworkException}, {@see ServerErrorException} and + * {@see RateLimitedException} are problems with *transport*. Retrying may + * help, and the client already retried on its own before throwing. + * + * That split is why `CepNotFoundException` is not "the HTTP 404 exception": + * `InvalidCepException` is thrown without any request being made at all, when + * the CEP is malformed before it ever leaves the machine. + */ +interface CepisException extends \Throwable +{ +} diff --git a/lib/cepis/Client.php b/lib/cepis/Client.php new file mode 100644 index 0000000..4e24b7f --- /dev/null +++ b/lib/cepis/Client.php @@ -0,0 +1,549 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * Client for the public CEP API at https://cep.is. + * + * Reuse one instance: the configuration is immutable and no method keeps state + * between calls. + * + * ```php + * $client = new Cepis\Client(timeout: 5.0, totalTimeout: 15.0); + * echo $client->lookup('01001-000')->localidade; // São Paulo + * ``` + * + * For a script, {@see Client::default()} is the shared, default-configured + * instance: + * + * ```php + * echo Cepis\Client::default()->lookup('01001-000')->uf; // SP + * ``` + * + * Retries cover 429, the transient 5xx in {@see Client::RETRIABLE_STATUS} and + * network failures, with exponential backoff and full jitter. 400 and 404 are + * deliberately absent from that set: both are definitive answers about the + * request, so retrying returns the same thing more slowly while burning + * someone else's quota. + */ +final class Client +{ + public const VERSION = '0.1.0'; + + /** + * Where the API lives, unless `baseUrl` says otherwise. + */ + public const DEFAULT_URL = 'https://cep.is'; + + /** + * Most CEPs the API accepts in a single batch request. + */ + public const MAX_BATCH = 500; + + /** + * Statuses worth trying again. + */ + public const RETRIABLE_STATUS = [429, 500, 502, 503, 504]; + + /** + * Where the API lives. A trailing slash is stripped, so it cannot produce + * doubled separators that some proxies normalise and others 404. + */ + public readonly string $baseUrl; + + /** + * Identifies the library and its version to the server. + */ + public readonly string $userAgent; + + /** + * How the GET is actually performed. {@see CurlTransport} unless the + * caller supplied one. + */ + public readonly Transport $transport; + + private static ?self $default = null; + + /** + * @param string $baseUrl Where the API lives. Point it at a local instance + * (the API repo brings one up on `http://localhost:8080` with + * `just up`) or at a test server. It is the only setting that usually + * changes between environments. + * @param float $timeout Seconds to allow for **a single attempt** — + * connection, response and body read together. It is not a cap on the + * whole call; that cap is `$totalTimeout`. + * @param int $maxAttempts How many attempts in total, counting the first. + * `1` disables retrying. The default of `3` covers the common case + * (one transient failure) without turning a real outage into a long + * wait. + * @param float $backoffBase Base of the exponential wait, in seconds. The + * actual wait is drawn from `[0, base * 2 ** (n - 1)]` — *full + * jitter*. The randomness is not decoration: without it, N clients + * that fail together come back together and reproduce the spike that + * took the service down. + * @param float $backoffMax Cap on each individual wait, in seconds. It + * applies to a server-sent `Retry-After` too, or a + * `Retry-After: 3600` from a misconfigured proxy would park the + * process for an hour. + * @param float|null $totalTimeout Cap on the whole call, attempts and + * waits included. `null` leaves `$maxAttempts` as the only bound. Set + * it in code serving a web request: without it the worst case is + * `$maxAttempts * $timeout` plus the sum of the backoffs. + * @param string|null $userAgent Overrides the `User-Agent`. The default + * identifies the library and its version, which is what lets the + * server diagnose an old client misbehaving. + * @param Transport|null $transport Overrides how the GET is performed. + * Supply one to route requests through the host application's HTTP + * layer — a WordPress plugin passes an implementation over + * `wp_remote_get`. `null` uses curl directly. + * + * @throws \InvalidArgumentException a setting is out of range. + */ + public function __construct( + string $baseUrl = self::DEFAULT_URL, + public readonly float $timeout = 10.0, + public readonly int $maxAttempts = 3, + public readonly float $backoffBase = 0.5, + public readonly float $backoffMax = 8.0, + public readonly ?float $totalTimeout = null, + ?string $userAgent = null, + ?Transport $transport = null, + ) { + if ($maxAttempts < 1) { + throw new \InvalidArgumentException('maxAttempts must be >= 1'); + } + if ($timeout <= 0) { + throw new \InvalidArgumentException('timeout must be > 0'); + } + if ($backoffBase < 0 || $backoffMax < 0) { + throw new \InvalidArgumentException('backoffBase and backoffMax must be >= 0'); + } + + $this->baseUrl = rtrim($baseUrl, '/'); + $this->userAgent = $userAgent ?? sprintf('cepis-php/%s (+https://cep.is)', self::VERSION); + $this->transport = $transport ?? new CurlTransport(); + } + + /** + * The shared, default-configured client. + * + * Built once because {@see Client} keeps no state between calls. It + * applies process-wide, third-party libraries that also use this package + * included. Anyone needing different settings should build their own with + * `new Client(...)`. + */ + public static function default(): self + { + return self::$default ??= new self(); + } + + /** + * Return the CEP's 8 digits, accepting the mask. + * + * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become + * `"01001000"`. + * + * This runs **before** any request: a 7-digit CEP is the caller's bug, and + * spending a round trip for the server to say so wastes both sides' time. + * + * @throws InvalidCepException unless exactly 8 digits remain. + */ + public static function normalizeCep(string $value): string + { + $cleaned = str_replace(['-', '.', ' '], '', trim($value)); + if (preg_match('/^\d{8}$/', $cleaned) !== 1) { + throw new InvalidCepException(sprintf('CEP must be 8 digits, got "%s"', $value)); + } + + return $cleaned; + } + + /** + * Look up a single CEP, masked or unmasked. + * + * ```php + * $client->lookup('01001-000')->localidade; // "São Paulo" + * ``` + * + * A 400 from the API is surfaced as {@see InvalidCepException} carrying + * the API's own message: the format already passed local validation, so + * the server knows something this client does not. + * + * @throws InvalidCepException the CEP is not 8 digits (no request is made). + * @throws CepNotFoundException well-formed, but not in the database. + * @throws RateLimitedException 429 after every attempt. + * @throws ServerErrorException 5xx after every attempt. + * @throws NetworkException the request never completed. + * @throws InvalidResponseException a success status with an unreadable body. + */ + public function lookup(string $cep): Address + { + $number = self::normalizeCep($cep); + [$status, $body] = $this->request('/' . $number); + + if ($status === 404) { + throw new CepNotFoundException( + sprintf('CEP %s does not exist in the DNE database', $number), + ); + } + if ($status === 400) { + throw new InvalidCepException( + self::errorMessage($body, sprintf('CEP %s rejected', $number)), + ); + } + + return Address::fromArray($this->decodeJsonObject($body)); + } + + /** + * Look up many CEPs in one batch. + * + * Returns an array of **the same length and in the same order** as the + * input, re-indexed from zero. A CEP that does not exist becomes `null` at + * its position — it does not vanish from the array, and it does not throw. + * That is the right difference from {@see Client::lookup()}: in a batch of + * a thousand, one bad CEP must not cost the other nine hundred and + * ninety-nine. + * + * A malformed CEP also becomes `null`, for the same reason, rather than + * throwing {@see InvalidCepException}. To know *which* one was malformed, + * validate first with {@see Client::normalizeCep()}. + * + * Input is chunked into blocks of {@see Client::MAX_BATCH} (the API's + * limit) automatically. Do not loop over {@see Client::lookup()}: a loop of + * 500 calls pays 500 TLS handshakes, the batch pays one. + * + * **Batches carry no coordinate.** The endpoint behind them is CSV, and + * the CSV has no `geo` column. Every {@see Address} returned here has a + * `null` `geo`, including for CEPs that do have one. Need the coordinate? + * Use {@see Client::lookup()}, one at a time. + * + * @param list<string> $ceps + * + * @return list<Address|null> + */ + public function lookupMany(array $ceps): array + { + if ($ceps === []) { + return []; + } + + $result = []; + foreach (array_chunk(array_values($ceps), self::MAX_BATCH) as $chunk) { + foreach ($this->lookupChunk($chunk) as $address) { + $result[] = $address; + } + } + + return $result; + } + + /** + * Query `GET /health`: does the API answer, and with which database. + * + * Does not throw for an API reporting itself degraded — that arrives in + * {@see Health::$status}, and checking health is exactly when you want the + * diagnosis rather than an exception. Network failures still throw. + */ + public function health(): Health + { + [, $body] = $this->request('/health', [200, 503]); + + return Health::fromArray($this->decodeJsonObject($body)); + } + + /** + * One chunk of at most {@see Client::MAX_BATCH} CEPs, in a single request. + * + * A blank CSV row means "not found": the API fills only the `cep` column + * and leaves the rest empty, precisely so it does not misalign whoever is + * consuming the batch in a spreadsheet. + * + * The API guarantees one row per requested CEP. Should that ever fail to + * hold — a proxy truncating, a cut response — the result is padded to the + * input length rather than returned short, because a shifted array + * silently pairs the wrong address with the wrong CEP. + * + * @param list<string> $ceps + * + * @return list<Address|null> + */ + private function lookupChunk(array $ceps): array + { + $query = http_build_query(['ceps' => implode(',', $ceps)]); + [, $body] = $this->request('/csv?' . $query); + + $rows = []; + foreach ($this->parseCsv($body) as $row) { + $rows[] = trim($row['localidade'] ?? '') === '' ? null : Address::fromArray($row); + } + + $wanted = count($ceps); + if (count($rows) !== $wanted) { + $rows = array_slice(array_pad($rows, $wanted, null), 0, $wanted); + } + + return $rows; + } + + /** + * Parse the CSV batch body into one associative row per record. + * + * `fgetcsv` over an in-memory stream is PHP's own RFC 4180 parser: it + * handles the quoted fields the API emits for values containing a comma, + * the `""` escape inside them, and a quoted field spanning a newline — + * which splitting on `\n` and calling `str_getcsv` per line would not. + * + * The empty `$escape` argument is required for RFC 4180: PHP's historical + * default treats a backslash as an escape character, which would swallow + * the closing quote of a field ending in one. + * + * @return list<array<string, string>> + */ + private function parseCsv(string $body): array + { + $text = $body; + if (str_starts_with($text, "\xEF\xBB\xBF")) { + $text = substr($text, 3); + } + + $stream = fopen('php://temp', 'r+'); + if ($stream === false) { + throw new InvalidResponseException('could not open a stream to parse the CSV'); + } + + try { + fwrite($stream, $text); + rewind($stream); + + $header = fgetcsv($stream, null, ',', '"', ''); + if (!is_array($header) || $header === [null]) { + throw new InvalidResponseException( + 'the batch response has no CSV header row: ' + . var_export(substr($body, 0, 120), true), + ); + } + $header = array_map(static fn ($name): string => trim((string) $name), $header); + $width = count($header); + + $rows = []; + while (($record = fgetcsv($stream, null, ',', '"', '')) !== false) { + if (!is_array($record) || $record === [null]) { + continue; + } + $record = array_slice(array_pad($record, $width, ''), 0, $width); + $rows[] = array_combine( + $header, + array_map(static fn ($value): string => (string) ($value ?? ''), $record), + ); + } + + return $rows; + } finally { + fclose($stream); + } + } + + /** + * Decode a JSON object body, or throw {@see InvalidResponseException}. + * + * @return array<string, mixed> + */ + private function decodeJsonObject(string $body): array + { + try { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new InvalidResponseException( + 'response is not valid JSON — something between you and the API is ' + . 'answering instead of it: ' . var_export(substr($body, 0, 120), true), + 0, + $exception, + ); + } + + if (!is_array($data) || (array_is_list($data) && $data !== [])) { + throw new InvalidResponseException( + 'expected a JSON object, got ' . get_debug_type($data), + ); + } + + return $data; + } + + /** + * Pull `mensagem` out of an error body, falling back to a default. + */ + private static function errorMessage(string $body, string $fallback): string + { + $data = json_decode($body, true); + if (is_array($data) && isset($data['mensagem']) && is_scalar($data['mensagem'])) { + $message = (string) $data['mensagem']; + if ($message !== '') { + return $message; + } + } + + return $fallback; + } + + /** + * Parse the `Retry-After` header in both RFC 9110 forms. + * + * It may arrive as a number of seconds (`"120"`) or as an HTTP date + * (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the + * common bug, and it makes a client ignore exactly the wait request that + * matters most. + */ + private static function retryAfterSeconds(?string $value): ?float + { + if ($value === null) { + return null; + } + $value = trim($value); + if ($value === '') { + return null; + } + if (is_numeric($value)) { + return max(0.0, (float) $value); + } + + $target = strtotime($value); + if ($target === false) { + return null; + } + + return max(0.0, (float) $target - microtime(true)); + } + + /** + * How long to wait before the next attempt. + * + * The server's `Retry-After` wins over the local calculation when present: + * it knows when it will be back, we are guessing. It still goes through + * the `backoffMax` cap. Otherwise the wait is drawn from `[0, ceiling]` + * rather than being the ceiling — see `$backoffBase` on why that matters. + */ + private function backoff(int $attempt, ?float $retryAfter): float + { + if ($retryAfter !== null) { + return min($retryAfter, $this->backoffMax); + } + + $ceiling = min($this->backoffBase * (2 ** ($attempt - 1)), $this->backoffMax); + + return (mt_rand() / mt_getrandmax()) * $ceiling; + } + + /** + * Perform the GET, with retries. Returns `[status, body]`. + * + * *Definitive* error statuses (400, 404) are returned normally, for the + * caller to turn into the right exception — only transient failures become + * exceptions here, and only once the attempts are exhausted. + * + * @param list<int> $acceptedStatus + * + * @return array{0: int, 1: string} + */ + private function request(string $path, array $acceptedStatus = [200]): array + { + $url = $this->baseUrl . $path; + $headers = [ + 'User-Agent' => $this->userAgent, + 'Accept' => 'application/json, text/csv;q=0.9, */*;q=0.1', + 'Accept-Encoding' => 'identity', + ]; + $deadline = $this->totalTimeout === null ? null : self::monotonic() + $this->totalTimeout; + + $lastStatus = null; + $lastRetryAfter = null; + $lastNetworkError = null; + + for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) { + if ($deadline !== null && self::monotonic() >= $deadline) { + break; + } + + try { + $response = $this->transport->get($url, $headers, $this->timeout); + + if ( + in_array($response->status, $acceptedStatus, true) + || !in_array($response->status, self::RETRIABLE_STATUS, true) + ) { + return [$response->status, $response->body]; + } + + $lastStatus = $response->status; + $lastRetryAfter = self::retryAfterSeconds($response->header('Retry-After')); + $lastNetworkError = null; + } catch (NetworkException $exception) { + $lastNetworkError = $exception; + $lastStatus = null; + $lastRetryAfter = null; + } + + if ($attempt === $this->maxAttempts) { + break; + } + + $wait = $this->backoff($attempt, $lastRetryAfter); + if ($deadline !== null) { + $remaining = $deadline - self::monotonic(); + if ($remaining <= 0) { + break; + } + $wait = min($wait, $remaining); + } + if ($wait > 0) { + usleep((int) round($wait * 1_000_000)); + } + } + + $this->fail($url, $lastStatus, $lastRetryAfter, $lastNetworkError); + } + + /** + * Turn the last failure into the exception that says what to do about it. + */ + private function fail( + string $url, + ?int $status, + ?float $retryAfter, + ?NetworkException $networkError, + ): never { + $attempts = sprintf('(after %d attempt(s))', $this->maxAttempts); + + if ($status === 429) { + throw new RateLimitedException( + sprintf('rate limit reached at %s %s', $url, $attempts), + $retryAfter, + ); + } + if ($status !== null) { + throw new ServerErrorException( + sprintf('the API answered %d at %s %s', $status, $url, $attempts), + $status, + ); + } + + throw new NetworkException( + sprintf( + 'could not reach %s %s: %s', + $url, + $attempts, + $networkError?->getMessage() ?? 'the call ran out of time before any attempt', + ), + 0, + $networkError, + ); + } + + private static function monotonic(): float + { + return hrtime(true) / 1_000_000_000; + } +} diff --git a/lib/cepis/CurlTransport.php b/lib/cepis/CurlTransport.php new file mode 100644 index 0000000..d22949b --- /dev/null +++ b/lib/cepis/CurlTransport.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The default {@see Transport}, built on ext-curl. + * + * curl is used directly rather than through an HTTP abstraction: this library + * makes one GET, and a wrapper for that would cost dependencies without buying + * anything. Hosts that need their own HTTP layer implement {@see Transport} + * instead. + * + * Each call opens its own handle, so one instance is safe to share. + */ +final class CurlTransport implements Transport +{ + /** + * @param bool $verifyTls Whether to verify the peer's certificate. Leave + * it alone. It exists for a lab against a self-signed local instance, + * and turning it off on the public API removes the only thing making + * the answer trustworthy. + * @param int $maxRedirects How many redirects to follow. + */ + public function __construct( + public readonly bool $verifyTls = true, + public readonly int $maxRedirects = 3, + ) { + } + + public function get(string $url, array $headers, float $timeout): Response + { + $handle = curl_init(); + if ($handle === false) { + throw new NetworkException('could not initialise a curl handle'); + } + + $lines = []; + foreach ($headers as $name => $value) { + $lines[] = $name . ': ' . $value; + } + + $milliseconds = max(1, (int) round($timeout * 1000)); + $received = []; + + curl_setopt_array($handle, [ + CURLOPT_URL => $url, + CURLOPT_HTTPGET => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $lines, + CURLOPT_CONNECTTIMEOUT_MS => $milliseconds, + CURLOPT_TIMEOUT_MS => $milliseconds, + CURLOPT_FOLLOWLOCATION => $this->maxRedirects > 0, + CURLOPT_MAXREDIRS => $this->maxRedirects, + CURLOPT_SSL_VERIFYPEER => $this->verifyTls, + CURLOPT_SSL_VERIFYHOST => $this->verifyTls ? 2 : 0, + CURLOPT_NOSIGNAL => true, + CURLOPT_HEADERFUNCTION => static function ($_handle, string $line) use (&$received): int { + $length = strlen($line); + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $received[trim($parts[0])][] = trim($parts[1]); + } + + return $length; + }, + ]); + + $body = curl_exec($handle); + $error = curl_error($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + unset($handle); + + if ($body === false || $status === 0) { + throw new NetworkException(sprintf( + 'could not reach %s: %s', + $url, + $error !== '' ? $error : 'the request did not complete', + )); + } + + return new Response($status, $received, (string) $body); + } +} diff --git a/lib/cepis/Geo.php b/lib/cepis/Geo.php new file mode 100644 index 0000000..c677622 --- /dev/null +++ b/lib/cepis/Geo.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The CEP's coordinate, when the database has one. + * + * The point is the **median** of the coordinates of that CEP's addresses, not + * the mean: one badly geocoded address 40 km away shifts a mean and does not + * move a median. + * + * Always check {@see Geo::$precisao} before using the coordinate for distance: + * + * - `"logradouro"`: the point is a stretch of street. `raio_m` lands around + * 100–150 m. Good for maps and for distance. + * - `"localidade"`: the CEP *is* the whole municipality (a city-wide general + * CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km. + * Computing shipping from this is wrong by tens of kilometres. + * + * `raio_m` is the radius containing 90% of that CEP's addresses. It exists + * precisely so that error cannot pass unnoticed. + */ +final class Geo +{ + /** + * Property names are the API's own JSON keys, `raio_m` included. See + * {@see Address} for why they are not translated or camel-cased. + * + * @param string $precisao Kept a plain string rather than an enum so a + * precision level the API adds later cannot break this client. + */ + public function __construct( + public readonly float $lat, + public readonly float $lon, + public readonly int $raio_m, + public readonly string $precisao, + ) { + } + + /** + * Build from the decoded `geo` block, or `null` when it is missing or + * malformed. + * + * A malformed `geo` is not worth losing the whole address over, since the + * rest of the response is still useful. + */ + public static function fromArray(mixed $data): ?self + { + if (!is_array($data)) { + return null; + } + foreach (['lat', 'lon', 'raio_m', 'precisao'] as $key) { + if (!isset($data[$key]) || !is_scalar($data[$key])) { + return null; + } + } + if (!is_numeric($data['lat']) || !is_numeric($data['lon']) || !is_numeric($data['raio_m'])) { + return null; + } + + return new self( + lat: (float) $data['lat'], + lon: (float) $data['lon'], + raio_m: (int) (float) $data['raio_m'], + precisao: (string) $data['precisao'], + ); + } + + /** + * `true` when the coordinate points at a street, not a municipality. + * + * Shorthand for `precisao === "logradouro"`, which is the check to make + * before using the point to measure distance. + */ + public function isPrecise(): bool + { + return $this->precisao === 'logradouro'; + } + + /** + * @return array{lat: float, lon: float, raio_m: int, precisao: string} + */ + public function toArray(): array + { + return [ + 'lat' => $this->lat, + 'lon' => $this->lon, + 'raio_m' => $this->raio_m, + 'precisao' => $this->precisao, + ]; + } +} diff --git a/lib/cepis/Health.php b/lib/cepis/Health.php new file mode 100644 index 0000000..08f7547 --- /dev/null +++ b/lib/cepis/Health.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The response of `GET /health`. + * + * Good for two things: checking that the API answers, and knowing **which + * version of the database** is live. The DNE database changes every two weeks; + * {@see Health::$dne} identifies it. + */ +final class Health +{ + /** + * Property names are the API's own JSON keys, `base_gerada_em` included. + * See {@see Address} for why they are not translated or camel-cased. + */ + public function __construct( + public readonly string $status, + public readonly string $dne, + public readonly int $ceps, + public readonly int $geo, + public readonly string $base_gerada_em, + ) { + } + + /** + * Build from the decoded response. + * + * @param array<string, mixed> $data + */ + public static function fromArray(array $data): self + { + return new self( + status: self::asString($data['status'] ?? ''), + dne: self::asString($data['dne'] ?? ''), + ceps: self::asInt($data['ceps'] ?? 0), + geo: self::asInt($data['geo'] ?? 0), + base_gerada_em: self::asString($data['base_gerada_em'] ?? ''), + ); + } + + /** + * `true` when the API reports itself healthy. + */ + public function isOk(): bool + { + return $this->status === 'ok'; + } + + /** + * @return array<string, mixed> + */ + public function toArray(): array + { + return [ + 'status' => $this->status, + 'dne' => $this->dne, + 'ceps' => $this->ceps, + 'geo' => $this->geo, + 'base_gerada_em' => $this->base_gerada_em, + ]; + } + + private static function asString(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } + + private static function asInt(mixed $value): int + { + return is_numeric($value) ? (int) (float) $value : 0; + } +} diff --git a/lib/cepis/InvalidCepException.php b/lib/cepis/InvalidCepException.php new file mode 100644 index 0000000..21a4593 --- /dev/null +++ b/lib/cepis/InvalidCepException.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The CEP is not 8 digits. + * + * Usually thrown **without** a request ever being made — the client checks the + * format before going to the network. It is also thrown when the API answers + * 400 to a CEP that passed local validation, in which case it carries the + * API's own message. + * + * Extends `\InvalidArgumentException`: this is an argument error. + */ +final class InvalidCepException extends \InvalidArgumentException implements CepisException +{ +} diff --git a/lib/cepis/InvalidResponseException.php b/lib/cepis/InvalidResponseException.php new file mode 100644 index 0000000..ee77984 --- /dev/null +++ b/lib/cepis/InvalidResponseException.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * A success status came back with a body that cannot be parsed. + * + * In practice this means something between you and the API is answering + * instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error + * page. Useful as a diagnosis: the problem is not in your code. + */ +final class InvalidResponseException extends \RuntimeException implements CepisException +{ +} diff --git a/lib/cepis/NetworkException.php b/lib/cepis/NetworkException.php new file mode 100644 index 0000000..506b615 --- /dev/null +++ b/lib/cepis/NetworkException.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The request never completed: DNS, refused connection, timeout, TLS. + * + * A {@see Transport} signals a failed request by throwing this. The client + * treats it as retriable and only lets it escape once every configured attempt + * has failed. + */ +final class NetworkException extends \RuntimeException implements CepisException +{ +} diff --git a/lib/cepis/RateLimitedException.php b/lib/cepis/RateLimitedException.php new file mode 100644 index 0000000..579fe7b --- /dev/null +++ b/lib/cepis/RateLimitedException.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The API (or the edge in front of it) answered 429 on every attempt. + */ +final class RateLimitedException extends \RuntimeException implements CepisException +{ + /** + * @param float|null $retryAfter What the server asked for, in seconds, or + * `null` if it did not say. The client already honoured this across + * the attempts it made; it is exposed for callers who want to + * reschedule the work. + */ + public function __construct( + string $message, + public readonly ?float $retryAfter = null, + ?\Throwable $previous = null, + ) { + parent::__construct($message, 0, $previous); + } +} diff --git a/lib/cepis/Response.php b/lib/cepis/Response.php new file mode 100644 index 0000000..f0ecb8d --- /dev/null +++ b/lib/cepis/Response.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * What a {@see Transport} hands back: an HTTP status, the response headers and + * the body as a string. + * + * Deliberately not a PSR-7 message. This library makes one GET and needs three + * things out of it; a plain value object keeps the {@see Transport} interface + * implementable in a few lines against whatever HTTP layer the host + * application already has. + */ +final class Response +{ + /** + * @param array<string, string|list<string>> $headers Response headers. + * Keys may be in any case and values may be a single string or a list + * of them; {@see Response::header()} handles both, so an implementer + * can pass through whatever their HTTP layer produced. + */ + public function __construct( + public readonly int $status, + public readonly array $headers = [], + public readonly string $body = '', + ) { + } + + /** + * Look a header up case-insensitively, returning the first value or `null` + * when it is absent. + */ + public function header(string $name): ?string + { + foreach ($this->headers as $key => $value) { + if (strcasecmp((string) $key, $name) !== 0) { + continue; + } + if (is_array($value)) { + $value = $value[array_key_first($value)] ?? null; + } + + return is_scalar($value) ? (string) $value : null; + } + + return null; + } +} diff --git a/lib/cepis/ServerErrorException.php b/lib/cepis/ServerErrorException.php new file mode 100644 index 0000000..731b23b --- /dev/null +++ b/lib/cepis/ServerErrorException.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * The API answered 5xx on every attempt. + */ +final class ServerErrorException extends \RuntimeException implements CepisException +{ + /** + * @param int $status HTTP status seen on the last attempt. + */ + public function __construct( + string $message, + public readonly int $status, + ?\Throwable $previous = null, + ) { + parent::__construct($message, 0, $previous); + } +} diff --git a/lib/cepis/Transport.php b/lib/cepis/Transport.php new file mode 100644 index 0000000..811ac69 --- /dev/null +++ b/lib/cepis/Transport.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Cepis; + +/** + * How the client actually performs a GET. + * + * The default is {@see CurlTransport}, and most callers never touch this. + * It exists because some hosts require their own HTTP layer: a WordPress + * plugin is expected to go through the WP HTTP API (`wp_remote_get`) so that + * site owners keep proxy support and the `http_request_args` filters they rely + * on, and a library hardcoding curl cannot be a good citizen there. + * + * One method, no PSR-7, no framework types: an implementation over + * `wp_remote_get` is about fifteen lines. See the README for that example. + * + * Implementations must be safe to reuse across calls; the client holds one for + * its lifetime. + */ +interface Transport +{ + /** + * Perform a GET and return the response, whatever its status. + * + * A non-2xx status is **not** an error here — return it and let the client + * decide whether it is retriable. Throw {@see NetworkException} only when + * the request did not complete at all: DNS failure, refused connection, + * timeout, TLS error. + * + * Retries, backoff and `Retry-After` are the client's job. An + * implementation should make exactly one request and should not retry. + * + * @param string $url Absolute URL, query string included. + * @param array<string, string> $headers Request headers to send, as + * name => value. + * @param float $timeout Seconds to allow for this single + * attempt. Implementations that distinguish connect from read timeouts + * should apply it to both. + * + * @throws NetworkException the request never completed. + */ + public function get(string $url, array $headers, float $timeout): Response; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4bf7efc --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "cepis-woocommerce", + "version": "0.1.0", + "private": true, + "description": "Test harness for CEP Autofill for WooCommerce: drives both WooCommerce checkouts in Chromium against a fake cep.is", + "license": "ISC", + "author": "Murilo Ijanc <[email protected]>", + "type": "module", + "scripts": { + "test": "bun test test/checkout.test.js" + }, + "devDependencies": { + "playwright-core": "^1.62.0", + "prettier": "^3.9.6" + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..8bdc1e5 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,80 @@ +<?xml version="1.0"?> +<ruleset name="cepis-woocommerce"> + <description>WordPress coding standards for the plugin's own code.</description> + + <file>.</file> + + <exclude-pattern>/node_modules/*</exclude-pattern> + <exclude-pattern>/dist/*</exclude-pattern> + <exclude-pattern>/vendor/*</exclude-pattern> + + <!-- + The bundled client is PSR-12 upstream and is copied in verbatim by + tools/update-vendor.sh. Reformatting it here would make every refresh a + merge conflict. + --> + <exclude-pattern>/lib/cepis/*</exclude-pattern> + + <arg name="extensions" value="php"/> + <arg name="basepath" value="."/> + <arg name="colors"/> + <arg value="sp"/> + + <rule ref="WordPress"> + <exclude name="WordPress.Files.FileName.InvalidClassFileName"/> + </rule> + + <config name="minimum_wp_version" value="6.9"/> + + <rule ref="WordPress.WP.I18n"> + <properties> + <property name="text_domain" type="array"> + <element value="cepis-woocommerce"/> + </property> + </properties> + </rule> + + <rule ref="WordPress.NamingConventions.PrefixAllGlobals"> + <properties> + <property name="prefixes" type="array"> + <element value="cepis_woo"/> + <element value="Cepis_Woo"/> + <element value="CEPIS_WOO"/> + <element value="cepis_woocommerce"/> + </property> + </properties> + </rule> + + <!-- + Properties of the bundled client, which is English camelCase by design + and is not this plugin's to rename. + --> + <rule ref="WordPress.NamingConventions.ValidVariableName"> + <properties> + <property name="allowed_custom_properties" type="array"> + <element value="retryAfter"/> + <element value="baseUrl"/> + <element value="userAgent"/> + </property> + </properties> + </rule> + + <rule ref="WordPress.WP.Capabilities"> + <properties> + <property name="custom_capabilities" type="array"> + <element value="manage_woocommerce"/> + </property> + </properties> + </rule> + + <!-- + Both files delete transients by prefix, and WordPress has no API for + that: `delete_transient()` needs the exact key. The query is prepared + and the result is fed straight back into `delete_transient()`, so the + caching the sniff asks for is the very thing being cleared. + --> + <rule ref="WordPress.DB.DirectDatabaseQuery"> + <exclude-pattern>/uninstall.php</exclude-pattern> + <exclude-pattern>/includes/class-cepis-woo-lookup.php</exclude-pattern> + </rule> +</ruleset> diff --git a/test/checkout.test.js b/test/checkout.test.js new file mode 100644 index 0000000..074b13a --- /dev/null +++ b/test/checkout.test.js @@ -0,0 +1,598 @@ +/** + * The plugin driven through a real browser against a real WooCommerce. + * + * Nothing here is stubbed except the API itself: WordPress, WooCommerce, the + * theme, the block checkout and its data store all run for real in the + * containers `compose.yml` describes, and Chromium drives them. The one fake is + * `fake-api.js` standing in for cep.is, so a test never depends on the network. + * + * Chromium is told to resolve `fakecep` to the loopback address; the WordPress + * container resolves the same name to the host gateway. Both sides therefore + * spell the API's address the same way, which is what lets one `base_url` + * setting serve the direct and the proxy mode. + * + * @module + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { readdirSync } from "node:fs"; +import { chromium } from "playwright-core"; +import { startFakeApi } from "./fake-api.js"; +import { FAKE_PORT, FAKE_URL, SITE, drainDebugLog, setOption, wp, wpEval } from "./wp.js"; + +const PRODUCT = wp(["option", "get", "cepis_test_product"]); +const BLOCKS_PAGE = wp(["option", "get", "cepis_test_blocks_page"]); +const CLASSIC_PAGE = wp(["option", "get", "cepis_test_classic_page"]); + +let browser; +let api; + +/** + * Locate the Chromium that Playwright installed. + * + * @returns {string} Path to the executable. + */ +function chromiumPath() { + if (process.env.CEPIS_CHROMIUM) { + return process.env.CEPIS_CHROMIUM; + } + + const root = `${process.env.HOME}/.cache/ms-playwright`; + const build = readdirSync(root) + .filter((name) => name.startsWith("chromium-")) + .sort() + .pop(); + + return `${root}/${build}/chrome-linux64/chrome`; +} + +beforeAll(async () => { + api = startFakeApi(FAKE_PORT); + browser = await chromium.launch({ + executablePath: chromiumPath(), + args: ["--no-sandbox", "--host-resolver-rules=MAP fakecep 127.0.0.1"], + }); + + setOption("cepis_woo_enabled", "yes"); + setOption("cepis_woo_base_url", FAKE_URL); + setOption("cepis_woo_mode", "direct"); + setOption("cepis_woo_overwrite", "auto"); + setOption("cepis_woo_focus_number", "yes"); + setOption("cepis_test_br_fields", "no"); + drainDebugLog(); +}); + +afterAll(async () => { + setOption("woocommerce_checkout_page_id", BLOCKS_PAGE); + await browser?.close(); + api?.stop(); +}); + +beforeEach(() => { + api.reset(); +}); + +/** + * Open a checkout with one product in the cart, collecting what the page asks + * the network for. + * + * @param {string} path Path of the checkout page. + * @returns {Promise<{page: import("playwright-core").Page, requests: Array<{url: string, body: string}>, close: () => Promise<void>}>} + */ +async function checkout(path) { + const context = await browser.newContext(); + const page = await context.newPage(); + const requests = []; + const errors = []; + + page.on("request", (request) => { + requests.push({ url: request.url(), body: request.postData() || "" }); + }); + page.on("pageerror", (error) => errors.push(String(error))); + + await page.goto(`${SITE}/?add-to-cart=${PRODUCT}`, { waitUntil: "domcontentloaded" }); + await page.goto(`${SITE}${path}`, { waitUntil: "domcontentloaded" }); + + return { + page, + requests, + errors, + async close() { + await context.close(); + }, + }; +} + +/** + * Wait until the Checkout block has hydrated and stopped talking to the Store + * API. + * + * The block seeds its address fields from the cart the server returns, and + * typing while that is in flight loses characters — the response resets the + * controlled input. That is WooCommerce's own behaviour on a freshly opened + * checkout, not something this plugin causes, so the test waits it out. + * + * @param {import("playwright-core").Page} page Page showing the block checkout. + */ +async function settled(page) { + await page.waitForSelector("#shipping-postcode"); + await page.waitForSelector(".wc-block-checkout:not(.is-loading)", { timeout: 30000 }); + await page.waitForFunction( + () => { + const store = window.wp && window.wp.data && window.wp.data.select("wc/store/cart"); + return !!(store && store.getCustomerData && store.getCustomerData().shippingAddress); + }, + undefined, + { timeout: 30000 }, + ); + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(500); +} + +/** + * Wait until the classic checkout has stopped moving. + * + * WooCommerce announces "added to your cart" in a banner that takes focus for + * screen-reader users a moment after the page loads. Typing before that has + * happened means the banner steals focus mid-CEP, which a real shopper never + * experiences — they are already looking at the form by then. + * + * @param {import("playwright-core").Page} page Page showing the classic checkout. + */ +async function ready(page) { + await page.waitForSelector("#billing_postcode"); + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(500); +} + +/** + * Type a CEP the way a shopper does. + * + * The block checkout's inputs are React-controlled and setting `value` in one + * go is not reliably seen; real key events are, and they exercise the widget's + * debounce as well. + * + * @param {import("playwright-core").Page} page Page under test. + * @param {string} selector CEP field. + * @param {string} cep Eight digits. + */ +async function typeCep(page, selector, cep) { + await page.click(selector); + await page.keyboard.press("ControlOrMeta+A"); + await page.locator(selector).pressSequentially(cep, { delay: 40 }); +} + +describe("plugin", () => { + test("is active", () => { + expect(wp(["plugin", "get", "cepis-woocommerce", "--field=status"])).toBe("active"); + }); + + test("declares the version WooCommerce reports", () => { + expect(wp(["plugin", "get", "cepis-woocommerce", "--field=version"])).toBe("0.1.0"); + }); +}); + +describe("block checkout", () => { + beforeAll(() => { + setOption("woocommerce_checkout_page_id", BLOCKS_PAGE); + setOption("cepis_woo_mode", "direct"); + }); + + test("fills the address and WooCommerce sees the state change", async () => { + const { page, requests, errors, 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 }, + ); + + const filled = await page.evaluate(() => ({ + street: document.querySelector("#shipping-address_1").value, + city: document.querySelector("#shipping-city").value, + state: document.querySelector("#shipping-state").value, + stateTag: document.querySelector("#shipping-state").tagName, + store: window.wp.data.select("wc/store/cart").getCustomerData().shippingAddress, + })); + + expect(filled.street).toBe("Praça da Sé"); + expect(filled.city).toBe("São Paulo"); + expect(filled.stateTag).toBe("SELECT"); + expect(filled.state).toBe("SP"); + expect(filled.store.state).toBe("SP"); + expect(filled.store.city).toBe("São Paulo"); + + await page.waitForFunction( + () => + performance + .getEntriesByType("resource") + .some((entry) => entry.name.includes("/wc/store/v1/")), + undefined, + { timeout: 15000 }, + ); + + const pushed = requests.filter( + (request) => request.url.includes("/wc/store/v1/") && request.body.includes('"state":"SP"'), + ); + expect(pushed.length).toBeGreaterThan(0); + expect(pushed.some((request) => request.body.includes("São Paulo"))).toBe(true); + + expect(api.state.requests.filter((entry) => entry.cep === "01001000")).toHaveLength(1); + expect(errors).toEqual([]); + + await close(); + }, 90000); + + test("replaces the state WooCommerce pre-selected", async () => { + const { page, close } = await checkout("/checkout/"); + + await settled(page); + expect(await page.inputValue("#shipping-state")).toBe("SP"); + + await typeCep(page, "#shipping-postcode", "20040020"); + await page.waitForFunction( + () => document.querySelector("#shipping-state").value === "RJ", + undefined, + { timeout: 15000 }, + ); + + const filled = await page.evaluate(() => ({ + street: document.querySelector("#shipping-address_1").value, + city: document.querySelector("#shipping-city").value, + store: window.wp.data.select("wc/store/cart").getCustomerData().shippingAddress, + })); + + expect(filled.street).toBe("Avenida Rio Branco"); + expect(filled.city).toBe("Rio de Janeiro"); + expect(filled.store.state).toBe("RJ"); + + await close(); + }, 90000); + + test("a CEP that does not exist shows a message and clears 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", "99999999"); + await page.waitForFunction(() => document.body.innerText.includes("CEP not found"), 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, + })); + + expect(after.street).toBe("Praça da Sé"); + expect(after.city).toBe("São Paulo"); + expect(after.state).toBe("SP"); + + await close(); + }, 90000); +}); + +describe("classic checkout", () => { + beforeAll(() => { + setOption("woocommerce_checkout_page_id", CLASSIC_PAGE); + setOption("cepis_woo_mode", "direct"); + }); + + test("fills the address and WooCommerce recalculates with the new state", async () => { + setOption("cepis_test_br_fields", "no"); + const { page, requests, 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 }, + ); + + const filled = await page.evaluate(() => ({ + street: document.querySelector("#billing_address_1").value, + city: document.querySelector("#billing_city").value, + state: document.querySelector("#billing_state").value, + stateTag: document.querySelector("#billing_state").tagName, + shown: (document.querySelector("#select2-billing_state-container") || {}).textContent || "", + status: (document.querySelector(".cepis-autofill-status") || {}).textContent || "", + })); + + expect(filled.street).toBe("Praça da Sé"); + expect(filled.city).toBe("São Paulo"); + expect(filled.stateTag).toBe("SELECT"); + expect(filled.state).toBe("SP"); + expect(filled.shown).toContain("São Paulo"); + expect(filled.status).toBe("Address found."); + + await page.waitForFunction( + () => window.jQuery && window.jQuery("form.checkout").length > 0, + undefined, + { timeout: 10000 }, + ); + await page.waitForTimeout(3000); + + const reviews = requests.filter( + (request) => + request.url.includes("wc-ajax=update_order_review") && + decodeURIComponent(request.body).includes("billing_state=SP"), + ); + expect(reviews.length).toBeGreaterThan(0); + + expect(errors).toEqual([]); + await close(); + }, 90000); + + test("fills neighbourhood and moves to the number when the store has those fields", async () => { + setOption("cepis_test_br_fields", "yes"); + const { page, close } = await checkout("/checkout-classico/"); + + await ready(page); + await page.waitForSelector("#billing_neighborhood"); + await typeCep(page, "#billing_postcode", "01001000"); + + await page.waitForFunction( + () => document.querySelector("#billing_neighborhood").value !== "", + undefined, + { timeout: 15000 }, + ); + + const filled = await page.evaluate(() => ({ + neighborhood: document.querySelector("#billing_neighborhood").value, + number: document.querySelector("#billing_number").value, + focused: document.activeElement.id, + })); + + expect(filled.neighborhood).toBe("Sé"); + expect(filled.number).toBe(""); + expect(filled.focused).toBe("billing_number"); + + setOption("cepis_test_br_fields", "no"); + await close(); + }, 90000); + + test("replaces the state WooCommerce pre-selected, and works without the Brazilian fields", async () => { + setOption("cepis_test_br_fields", "no"); + const { page, requests, errors, close } = await checkout("/checkout-classico/"); + + await ready(page); + expect(await page.inputValue("#billing_state")).toBe("SP"); + + await typeCep(page, "#billing_postcode", "20040020"); + + await page.waitForFunction( + () => document.querySelector("#billing_state").value === "RJ", + undefined, + { timeout: 15000 }, + ); + + const filled = await page.evaluate(() => ({ + street: document.querySelector("#billing_address_1").value, + city: document.querySelector("#billing_city").value, + state: document.querySelector("#billing_state").value, + shown: (document.querySelector("#select2-billing_state-container") || {}).textContent || "", + address2: document.querySelector("#billing_address_2").value, + hasNeighborhood: !!document.querySelector("#billing_neighborhood"), + hasNumber: !!document.querySelector("#billing_number"), + })); + + expect(filled.hasNeighborhood).toBe(false); + expect(filled.hasNumber).toBe(false); + expect(filled.street).toBe("Avenida Rio Branco"); + expect(filled.city).toBe("Rio de Janeiro"); + expect(filled.state).toBe("RJ"); + expect(filled.shown).toContain("Rio de Janeiro"); + expect(filled.address2).toBe(""); + + await page.waitForTimeout(3000); + const reviews = requests.filter( + (request) => + request.url.includes("wc-ajax=update_order_review") && + decodeURIComponent(request.body).includes("billing_state=RJ"), + ); + expect(reviews.length).toBeGreaterThan(0); + + expect(errors).toEqual([]); + + await close(); + }, 90000); + + test("a CEP that does not exist shows a message and clears nothing", async () => { + const { page, 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", "99999999"); + await page.waitForFunction( + () => + (document.querySelector(".cepis-autofill-status") || {}).textContent === + "CEP not found. Check the number or fill the address in by hand.", + 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"), + })); + + 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"); + + await close(); + }, 90000); +}); + +describe("proxy mode", () => { + beforeAll(() => { + setOption("woocommerce_checkout_page_id", CLASSIC_PAGE); + setOption("cepis_woo_mode", "proxy"); + wpEval( + "global $wpdb; $wpdb->query( \"DELETE FROM {$wpdb->options} WHERE option_name LIKE '%_transient_%cepis_woo_%'\" );", + ); + }); + + afterAll(() => { + setOption("cepis_woo_mode", "direct"); + }); + + test("the browser talks to this site and PHP talks to the API", async () => { + const { page, requests, 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: 20000 }, + ); + + expect(await page.inputValue("#billing_address_1")).toBe("Praça da Sé"); + expect(await page.inputValue("#billing_city")).toBe("São Paulo"); + expect(await page.inputValue("#billing_state")).toBe("SP"); + + const proxied = requests.filter((request) => + /\/cepis\/v1\/lookup\/[A-Za-z0-9]+\/01001000/.test(request.url), + ); + expect(proxied.length).toBeGreaterThan(0); + expect(requests.some((request) => request.url.includes("fakecep"))).toBe(false); + + 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(errors).toEqual([]); + await close(); + }, 90000); + + test("the answer is cached, so a second shopper costs no request", async () => { + api.reset(); + const first = await checkout("/checkout-classico/"); + await ready(first.page); + await typeCep(first.page, "#billing_postcode", "20040020"); + await first.page.waitForFunction( + () => document.querySelector("#billing_address_1").value !== "", + undefined, + { timeout: 20000 }, + ); + await first.close(); + + expect(api.state.requests.filter((entry) => entry.cep === "20040020").length).toBe(1); + + const second = await checkout("/checkout-classico/"); + await ready(second.page); + await typeCep(second.page, "#billing_postcode", "20040020"); + await second.page.waitForFunction( + () => document.querySelector("#billing_address_1").value !== "", + undefined, + { timeout: 20000 }, + ); + expect(await second.page.inputValue("#billing_city")).toBe("Rio de Janeiro"); + await second.close(); + + expect(api.state.requests.filter((entry) => entry.cep === "20040020").length).toBe(1); + }, 120000); + + test("the REST route refuses a request without a valid token", async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + + const withoutToken = await page.evaluate(async (site) => { + const response = await fetch(`${site}/wp-json/cepis/v1/lookup/01001000`); + return response.status; + }, SITE); + + const wrongToken = await page.evaluate(async (site) => { + const response = await fetch(`${site}/wp-json/cepis/v1/lookup/deadbeef00/01001000`); + return { status: response.status, body: await response.text() }; + }, SITE); + + expect(withoutToken).toBe(404); + expect(wrongToken.status).toBe(403); + expect(wrongToken.body).toContain("cepis_invalid_token"); + + await context.close(); + }, 60000); +}); + +describe("translations", () => { + beforeAll(() => { + setOption("woocommerce_checkout_page_id", CLASSIC_PAGE); + setOption("cepis_test_locale", "pt_BR"); + }); + + afterAll(() => { + setOption("cepis_test_locale", ""); + }); + + test("a store in Portuguese gets the shipped Portuguese messages", async () => { + const { page, close } = await checkout("/checkout-classico/"); + + await ready(page); + await typeCep(page, "#billing_postcode", "99999999"); + + await page.waitForFunction( + () => + (document.querySelector(".cepis-autofill-status") || {}).textContent === + "CEP não encontrado. Confira o número ou preencha o endereço manualmente.", + undefined, + { timeout: 15000 }, + ); + + await close(); + }, 90000); +}); + +describe("housekeeping", () => { + test("uninstalling removes every option and cached answer", () => { + wpEval( + 'define( "WP_UNINSTALL_PLUGIN", "cepis-woocommerce/cepis-woocommerce.php" );' + + 'include WP_PLUGIN_DIR . "/cepis-woocommerce/uninstall.php";', + ); + + const left = wpEval( + "global $wpdb;" + + "echo (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE 'cepis_woo_%' OR option_name LIKE '%transient%cepis_woo_%'\" );", + ); + + expect(left).toBe("0"); + + setOption("cepis_woo_enabled", "yes"); + setOption("cepis_woo_base_url", FAKE_URL); + setOption("cepis_woo_mode", "direct"); + }); + + test("WP_DEBUG logged nothing while all of that happened", () => { + const log = drainDebugLog(); + const ours = log + .split("\n") + .filter((line) => line.trim() !== "") + .filter((line) => !line.includes("/wp-content/plugins/woocommerce/")) + .filter((line) => !line.includes("/wp-includes/")); + + expect(ours).toEqual([]); + }); +}); diff --git a/test/compose.yml b/test/compose.yml new file mode 100644 index 0000000..ff0cbdd --- /dev/null +++ b/test/compose.yml @@ -0,0 +1,61 @@ +name: cepis-woocommerce-test + +services: + db: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: wordpress + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: wordpress + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uwordpress", "-pwordpress"] + interval: 3s + timeout: 5s + retries: 30 + + wordpress: + image: wordpress:latest + depends_on: + db: + condition: service_healthy + ports: + - "8090:80" + extra_hosts: + - "fakecep:host-gateway" + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DB_NAME: wordpress + WORDPRESS_DEBUG: "1" + WORDPRESS_CONFIG_EXTRA: | + define( 'WP_DEBUG', true ); + define( 'WP_DEBUG_LOG', true ); + define( 'WP_DEBUG_DISPLAY', false ); + define( 'SCRIPT_DEBUG', true ); + define( 'DISABLE_WP_CRON', true ); + volumes: + - wp:/var/www/html + - ..:/var/www/html/wp-content/plugins/cepis-woocommerce + + cli: + image: wordpress:cli + depends_on: + - wordpress + user: "33:33" + extra_hosts: + - "fakecep:host-gateway" + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DB_NAME: wordpress + WP_CLI_CACHE_DIR: /tmp/wp-cli-cache + volumes: + - wp:/var/www/html + - ..:/var/www/html/wp-content/plugins/cepis-woocommerce + entrypoint: ["tail", "-f", "/dev/null"] + +volumes: + wp: diff --git a/test/fake-api.js b/test/fake-api.js new file mode 100644 index 0000000..4b6ac39 --- /dev/null +++ b/test/fake-api.js @@ -0,0 +1,104 @@ +/** + * A fake cep.is, so the test suite never touches the network. + * + * Same routes, bodies and status codes as the real API, plus a request log the + * tests assert on. It listens on every interface because two different clients + * have to reach it: the browser on the host, and PHP inside the WordPress + * container through the `fakecep` host alias. + * + * @module + */ + +const ADDRESSES = { + "01001000": { + cep: "01001000", + logradouro: "Praça da Sé", + complemento: "lado ímpar", + unidade: "", + bairro: "Sé", + localidade: "São Paulo", + uf: "SP", + estado: "São Paulo", + regiao: "Sudeste", + ibge: "3550308", + gia: "1004", + ddd: "11", + siafi: "7107", + geo: { lat: -23.567202, lon: -46.649659, raio_m: 1084, precisao: "logradouro" }, + }, + 20040020: { + cep: "20040020", + logradouro: "Avenida Rio Branco", + complemento: "até 0999 - lado ímpar", + unidade: "", + bairro: "Centro", + localidade: "Rio de Janeiro", + uf: "RJ", + estado: "Rio de Janeiro", + regiao: "Sudeste", + ibge: "3304557", + gia: "", + ddd: "21", + siafi: "6001", + }, +}; + +const HEADERS = { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json; charset=utf-8", +}; + +/** + * Start the fake API. + * + * `state.requests` collects `{cep, userAgent}` for every lookup, which is how + * the proxy tests prove the request came from PHP rather than the browser. + * + * @param {number} port Port to listen on. + * @returns {{port: number, state: {requests: Array<{cep: string, userAgent: string}>}, reset: () => void, stop: () => void}} + */ +export function startFakeApi(port) { + const state = { requests: [] }; + + const server = Bun.serve({ + port, + hostname: "0.0.0.0", + fetch(request) { + const cep = new URL(request.url).pathname.slice(1); + state.requests.push({ cep, userAgent: request.headers.get("user-agent") || "" }); + + if (!/^\d{8}$/.test(cep)) { + return new Response( + JSON.stringify({ erro: "cep_invalido", mensagem: "CEP deve ter 8 dígitos" }), + { status: 400, headers: HEADERS }, + ); + } + + 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 }, + ); + } + + return new Response(JSON.stringify(address), { status: 200, headers: HEADERS }); + }, + }); + + return { + port: server.port, + state, + reset() { + state.requests.length = 0; + }, + stop() { + server.stop(true); + }, + }; +} + +if (import.meta.main) { + const api = startFakeApi(Number(process.argv[2] || 8899)); + console.log(`fake cep.is on :${api.port}`); +} diff --git a/test/setup.sh b/test/setup.sh new file mode 100755 index 0000000..6925386 --- /dev/null +++ b/test/setup.sh @@ -0,0 +1,158 @@ +#!/bin/sh +# +# Stand up the WordPress + WooCommerce store the browser tests drive. +# +# Idempotent: running it again on a live stack reconfigures rather than +# rebuilds. `make clean` is what throws the volumes away. + +set -eu + +cd "$(dirname "$0")" + +SITE="http://127.0.0.1:8090" + +wp() { + docker compose exec -T cli wp "$@" +} + +printf 'bringing the stack up\n' +docker compose up -d >/dev/null + +printf 'waiting for the web server\n' +i=0 +while [ "$i" -lt 90 ]; do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$SITE/" 2>/dev/null || echo 000) + [ "$code" != "000" ] && break + i=$((i + 1)) + sleep 2 +done +[ "$code" = "000" ] && { printf 'web server never answered\n' >&2; exit 1; } + +if ! wp core is-installed >/dev/null 2>&1; then + printf 'installing WordPress\n' + wp core install \ + --url="$SITE" \ + --title="CEP Test Store" \ + --admin_user=admin \ + --admin_password=admin \ + --skip-email >/dev/null +fi + +wp rewrite structure '/%postname%/' >/dev/null 2>&1 +wp rewrite flush >/dev/null 2>&1 + +if ! wp plugin is-installed woocommerce >/dev/null 2>&1; then + printf 'installing WooCommerce\n' + wp plugin install woocommerce >/dev/null +fi +wp plugin activate woocommerce >/dev/null + +printf 'configuring the store\n' +wp option update woocommerce_store_address "Praça da Sé" >/dev/null +wp option update woocommerce_store_city "São Paulo" >/dev/null +wp option update woocommerce_default_country "BR:SP" >/dev/null +wp option update woocommerce_store_postcode "01001000" >/dev/null +wp option update woocommerce_currency BRL >/dev/null +wp option update woocommerce_allowed_countries all >/dev/null +wp option update woocommerce_enable_guest_checkout yes >/dev/null +wp option update woocommerce_enable_checkout_login_reminder no >/dev/null +wp option update woocommerce_calc_taxes no >/dev/null +wp option update woocommerce_cod_settings \ + '{"enabled":"yes","title":"Pagamento na entrega","description":"","instructions":""}' \ + --format=json >/dev/null +wp eval 'if ( ! WC_Shipping_Zones::get_zone( 0 )->get_shipping_methods() ) { WC_Shipping_Zones::get_zone( 0 )->add_shipping_method( "flat_rate" ); }' >/dev/null + +wp eval ' +$id = (int) get_option( "cepis_test_product", 0 ); +if ( ! $id || ! wc_get_product( $id ) ) { + $product = new WC_Product_Simple(); + $product->set_name( "Caneca" ); + $product->set_regular_price( "25" ); + $product->set_virtual( false ); + $id = $product->save(); + update_option( "cepis_test_product", $id ); +} +' >/dev/null + +wp eval ' +$blocks = 0; +foreach ( get_pages( array( "post_status" => "publish" ) ) as $page ) { + if ( has_block( "woocommerce/checkout", $page ) ) { + $blocks = (int) $page->ID; + break; + } +} +if ( ! $blocks ) { + WP_CLI::error( "no page holds the woocommerce/checkout block" ); +} +update_option( "cepis_test_blocks_page", $blocks ); +update_option( "woocommerce_checkout_page_id", $blocks ); + +$classic = get_page_by_path( "checkout-classico" ); +if ( ! $classic ) { + $id = wp_insert_post( array( + "post_title" => "Checkout classico", + "post_name" => "checkout-classico", + "post_content" => "[woocommerce_checkout]", + "post_status" => "publish", + "post_type" => "page", + ) ); +} else { + $id = (int) $classic->ID; +} +update_option( "cepis_test_classic_page", $id ); +' >/dev/null + +printf 'installing the fixture that imitates Brazilian Market on WooCommerce\n' +docker compose exec -T cli sh -c 'mkdir -p /var/www/html/wp-content/mu-plugins && cat > /var/www/html/wp-content/mu-plugins/cepis-test-br-fields.php' <<'PHP' +<?php +/** + * Adds the house-number and neighbourhood fields that "Brazilian Market on + * WooCommerce" adds, so the plugin can be tested with and without them. + * Switched by the `cepis_test_br_fields` option. + */ + +function cepis_test_br_fields( $fields, $prefix ) { + if ( 'yes' !== get_option( 'cepis_test_br_fields' ) ) { + return $fields; + } + + $fields[ $prefix . '_number' ] = array( + 'label' => 'Número', + 'required' => false, + 'class' => array( 'form-row-first', 'address-field' ), + 'priority' => 51, + ); + $fields[ $prefix . '_neighborhood' ] = array( + 'label' => 'Bairro', + 'required' => false, + 'class' => array( 'form-row-last', 'address-field' ), + 'priority' => 52, + ); + + return $fields; +} + +add_filter( 'woocommerce_billing_fields', function ( $fields ) { + return cepis_test_br_fields( $fields, 'billing' ); +} ); + +add_filter( 'woocommerce_shipping_fields', function ( $fields ) { + return cepis_test_br_fields( $fields, 'shipping' ); +} ); + +/** + * Force the site locale from an option, so the shipped translation can be + * exercised without downloading WordPress's own language pack. + */ +add_filter( 'locale', function ( $locale ) { + $forced = get_option( 'cepis_test_locale' ); + + return $forced ? $forced : $locale; +} ); +PHP + +wp plugin activate cepis-woocommerce >/dev/null + +printf 'ready: %s\n' "$SITE" diff --git a/test/wp.js b/test/wp.js new file mode 100644 index 0000000..ab2d2a7 --- /dev/null +++ b/test/wp.js @@ -0,0 +1,88 @@ +/** + * Talking to the WordPress under test. + * + * Everything goes through WP-CLI in the `cli` container of `compose.yml`, so + * the tests configure the store exactly the way a store owner would. + * + * @module + */ + +import { spawnSync } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** Where the site answers, from the host. */ +export const SITE = "http://127.0.0.1:8090"; + +/** Hostname the fake API is reached by, from both the browser and PHP. */ +export const FAKE_HOST = "fakecep"; + +/** Port the fake API listens on. */ +export const FAKE_PORT = 8899; + +/** Base URL of the fake API, as both sides must spell it. */ +export const FAKE_URL = `http://${FAKE_HOST}:${FAKE_PORT}`; + +/** + * Run a WP-CLI command in the container. + * + * @param {string[]} args Arguments after `wp`. + * @returns {string} Trimmed stdout. + */ +export function wp(args) { + const result = spawnSync("docker", ["compose", "exec", "-T", "cli", "wp", ...args], { + cwd: HERE, + encoding: "utf8", + }); + + if (result.status !== 0) { + throw new Error(`wp ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`); + } + + return String(result.stdout).trim(); +} + +/** + * Run PHP inside WordPress. + * + * @param {string} code PHP to evaluate. + * @returns {string} Trimmed stdout. + */ +export function wpEval(code) { + return wp(["eval", code]); +} + +/** + * Set one option. + * + * @param {string} name Option name. + * @param {string} value Option value. + */ +export function setOption(name, value) { + wp(["option", "update", name, value]); +} + +/** + * Read the WordPress debug log and clear it. + * + * @returns {string} Whatever had been logged. + */ +export function drainDebugLog() { + const result = spawnSync( + "docker", + [ + "compose", + "exec", + "-T", + "cli", + "sh", + "-c", + "cat /var/www/html/wp-content/debug.log 2>/dev/null; : > /var/www/html/wp-content/debug.log 2>/dev/null || true", + ], + { cwd: HERE, encoding: "utf8" }, + ); + + return String(result.stdout || "").trim(); +} diff --git a/tools/install-phpcs.sh b/tools/install-phpcs.sh new file mode 100755 index 0000000..63ae7b2 --- /dev/null +++ b/tools/install-phpcs.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# +# Fetch PHP_CodeSniffer and the WordPress standard into .phpcs/. +# +# Not a dependency of the plugin: nothing here ships, and a bare checkout lints +# with `php -l` alone. Run this once to get the full WordPress review that +# `make lint` will then include. + +set -eu + +root=$(cd "$(dirname "$0")/.." && pwd) +target="$root/.phpcs" + +mkdir -p "$target" + +docker run --rm -v "$target":/app -w /app composer:2 sh -c ' + composer config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true && + composer require --dev --no-interaction --quiet \ + squizlabs/php_codesniffer \ + wp-coding-standards/wpcs \ + dealerdirect/phpcodesniffer-composer-installer +' + +printf 'phpcs installed in .phpcs/ — `make lint` will use it\n' diff --git a/tools/update-vendor.sh b/tools/update-vendor.sh new file mode 100755 index 0000000..33dc824 --- /dev/null +++ b/tools/update-vendor.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# +# Refresh the two bundled dependencies from their own repositories. +# +# Neither is installed at runtime: a WordPress site is deployed by copying +# files and never runs `composer install` or `bun install`, so both live in +# this repository and are updated deliberately, by running this. +# +# Usage: tools/update-vendor.sh [path-to-cepis-php] [path-to-cepis-autofill] + +set -eu + +root=$(cd "$(dirname "$0")/.." && pwd) +php_src=${1:-../cepis-php} +js_src=${2:-../cepis-autofill} + +[ -d "$php_src/src" ] || { printf 'no PHP client at %s\n' "$php_src" >&2; exit 1; } +[ -d "$js_src" ] || { printf 'no widget at %s\n' "$js_src" >&2; exit 1; } + +printf 'refreshing lib/cepis from %s\n' "$php_src" +rm -f "$root"/lib/cepis/*.php +cp "$php_src"/src/*.php "$root/lib/cepis/" + +printf 'building the widget in %s\n' "$js_src" +( cd "$js_src" && make build >/dev/null ) + +printf 'refreshing assets/js/cepis-autofill.min.js\n' +cp "$js_src/dist/cepis-autofill.min.js" "$root/assets/js/cepis-autofill.min.js" + +printf 'done — review the diff and bump CEPIS_WOO_VERSION if anything changed\n' diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..a3c0333 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,41 @@ +<?php +/** + * Remove every trace of the plugin when it is deleted. + * + * Runs on delete, not on deactivate: a store owner switching the plugin off to + * debug something should not lose their settings. + * + * @package CepisWooCommerce + */ + +declare(strict_types=1); + +defined( 'WP_UNINSTALL_PLUGIN' ) || exit; + +$cepis_woo_options = array( + 'cepis_woo_enabled', + 'cepis_woo_mode', + 'cepis_woo_overwrite', + 'cepis_woo_focus_number', + 'cepis_woo_base_url', +); + +foreach ( $cepis_woo_options as $cepis_woo_option ) { + delete_option( $cepis_woo_option ); +} + +global $wpdb; + +$cepis_woo_names = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", + $wpdb->esc_like( '_transient_cepis_woo_' ) . '%', + $wpdb->esc_like( '_transient_timeout_cepis_woo_' ) . '%' + ) +); + +foreach ( (array) $cepis_woo_names as $cepis_woo_name ) { + delete_option( (string) $cepis_woo_name ); +} + +wp_cache_flush(); |