From 64a040957b25d92229abf25023ab29a129889f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Sun, 14 Jun 2026 12:52:35 -0300 Subject: [PATCH 1/4] Fix audit findings: portability, validation, docs, and tests Bugs: - Define %||% internally; it is base R only since 4.4.0 but the package declares R (>= 4.2.0), so it failed to load on R 4.2-4.3. - Normalize recipient `number` in send_*() (strip spaces/dashes/parens/ leading +), passing JIDs (...@g.us) through unchanged. The documented "+55..." form was previously sent verbatim and rejected by the API. - .normalize_media() no longer treats a mistyped file path as base64; it requires padded base64 and gives a path-aware error otherwise. - Correct LICENSE copyright holder (was an unrelated package). Cleanup: - Remove dead apikey-redaction branch in the verbose logger (the API key is only ever sent as a header). - Unify API URL across README/DESCRIPTION (evoapicloud.com); align years. - Ignore .Rhistory/.DS_Store/.claude and build artifacts in .Rbuildignore/.gitignore. Tests: - Add a testthat suite for the offline helpers and the argument- validation guard clauses. Release hygiene: - Bump to 0.1.1 and refresh Date (0.1.0 is already on CRAN). R CMD check --as-cran: 0 errors, 0 warnings, 0 package notes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .Rbuildignore | 5 ++ .gitignore | 4 +- DESCRIPTION | 4 +- LICENSE | 4 +- NEWS.md | 41 ++++++++++++++- R/evolution.R | 63 +++++++++++++++++++---- R/zzz.R | 4 ++ README.Rmd | 4 +- README.md | 4 +- man/dot-normalize_number.Rd | 21 ++++++++ man/send_buttons.Rd | 4 +- man/send_list.Rd | 4 +- man/send_location.Rd | 4 +- man/send_media.Rd | 4 +- man/send_poll.Rd | 4 +- man/send_sticker.Rd | 4 +- man/send_text.Rd | 4 +- man/send_whatsapp_audio.Rd | 4 +- tests/testthat.R | 12 +++++ tests/testthat/test-helpers.R | 45 +++++++++++++++++ tests/testthat/test-jid.R | 17 +++++++ tests/testthat/test-normalize_media.R | 43 ++++++++++++++++ tests/testthat/test-validation.R | 72 +++++++++++++++++++++++++++ 23 files changed, 348 insertions(+), 27 deletions(-) create mode 100644 man/dot-normalize_number.Rd create mode 100644 tests/testthat.R create mode 100644 tests/testthat/test-helpers.R create mode 100644 tests/testthat/test-jid.R create mode 100644 tests/testthat/test-normalize_media.R create mode 100644 tests/testthat/test-validation.R diff --git a/.Rbuildignore b/.Rbuildignore index b05d61b..1069d7d 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -6,3 +6,8 @@ ^README\.Rmd$ ^cran-comments\.md$ ^CRAN-SUBMISSION$ +^\.Rhistory$ +^\.DS_Store$ +^\.claude$ +^evolution\.Rcheck$ +^evolution_.*\.tar\.gz$ diff --git a/.gitignore b/.gitignore index b8aadc8..35d6dd6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ .RData .Ruserdata .DS_Store -.DS_Store +.claude +evolution.Rcheck +evolution_*.tar.gz diff --git a/DESCRIPTION b/DESCRIPTION index 3c239b5..6f0c2b4 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: evolution Type: Package Title: A Client for 'Evolution Cloud API' -Date: 2026-02-27 -Version: 0.1.0 +Date: 2026-06-14 +Version: 0.1.1 Authors@R: c( person("Andre", "Leite", email = "leite@castlab.org", role = c("aut", "cre")), person("Hugo", "Vasconcelos", email = "hugo.vasconcelos@ufpe.br", role = "aut"), diff --git a/LICENSE b/LICENSE index 87e8d21..96a0068 100644 --- a/LICENSE +++ b/LICENSE @@ -1,2 +1,2 @@ -YEAR: 2025 -COPYRIGHT HOLDER: webdav authors +YEAR: 2025-2026 +COPYRIGHT HOLDER: Andre Leite, Hugo Vasconcelos, Diogo Bezerra diff --git a/NEWS.md b/NEWS.md index 52c540f..2c30254 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,43 @@ -# evolution (development version) +# evolution 0.1.1 + +## Bug fixes + +* Defined the null-coalescing operator `%||%` internally. It was used by + `.evo_post()` and `send_contact()` but is only part of base R since + 4.4.0, so the package failed to load on R 4.2--4.3 (the declared + minimum is R 4.2.0). + +* `send_*()` functions now normalise the recipient `number`, stripping + formatting characters (spaces, dashes, parentheses and a leading `+`) + before the request. Previously a number such as `"+5581999990000"` was + sent verbatim and could be rejected by the API, even though it was + shown as valid in the documentation. JIDs (values containing `@`, e.g. + group ids `...@g.us`) are passed through unchanged. + +* `.normalize_media()` no longer silently treats a mistyped file path as + base64. It now requires standard (padded) base64 and emits a + path-aware error pointing at the resolved location when a file is not + found. + +* Corrected the copyright holder in `LICENSE`, which still referenced an + unrelated package. + +## Documentation + +* Unified the API reference URL across `README` and `DESCRIPTION` + (`https://evoapicloud.com`). + +* Removed a dead "apikey" redaction branch in the verbose logger (the + API key is only ever sent as a header, never in the request body). + +## Internal + +* Added a `testthat` suite covering the offline helpers (`jid()`, + `.normalize_media()`, `.compact()`, `.evo_path()`, + `.assert_scalar_string()`) and the argument-validation guard clauses of + the exported `send_*()` functions and `evo_client()`. + +* Added `.Rhistory` and `.DS_Store` to `.Rbuildignore`. # evolution 0.1.0 diff --git a/R/evolution.R b/R/evolution.R index 91914dc..9dd2be2 100644 --- a/R/evolution.R +++ b/R/evolution.R @@ -40,7 +40,7 @@ evo_client <- function(base_url, api_key, instance) { req <- httr2::request(sub("/+$", "", base_url)) |> httr2::req_headers(apikey = api_key, `Content-Type` = "application/json") |> - httr2::req_user_agent("evolution-r/0.1.0 (httr2)") |> + httr2::req_user_agent("evolution-r/0.1.1 (httr2)") |> httr2::req_retry(max_tries = 3) structure(list(req = req, instance = instance), class = "evo_client") @@ -105,8 +105,9 @@ print.evo_client <- function(x, ...) { if (isTRUE(verbose)) { cli::cli_rule(left = "{.strong evoapi} POST {path}") cli::cli_alert_info("Timeout: {timeout}s") + # The apikey travels in the request header, never in the body, so there is + # nothing to redact here; only large media payloads need truncating. show <- body - if (!is.null(show$apikey)) show$apikey <- "" if (!is.null(show$media) && nchar(show$media) > 80) { show$media <- paste0(substr(show$media, 1, 40), "...") } @@ -219,7 +220,9 @@ jid <- function(number) { #' #' @param client An [evo_client()] object. #' @param number Character. Recipient number with country code -#' (e.g., `"5581999990000"` or `"+5581999990000"`). +#' (e.g., `"5581999990000"` or `"+5581999990000"`). Formatting characters +#' (spaces, dashes, parentheses and a leading `+`) are stripped before the +#' request; a JID such as a group id `...@@g.us` is passed through unchanged. #' @param text Character. Message body. #' @param delay Integer (ms). Optional presence delay before sending. #' Simulates typing before the message is sent. @@ -247,6 +250,7 @@ send_text <- function(client, number, text, delay = NULL, mentioned = NULL, quoted = NULL, verbose = FALSE) { .assert_scalar_string(number, "number") .assert_scalar_string(text, "text") + number <- .normalize_number(number) body <- list( number = number, @@ -340,6 +344,7 @@ send_media <- function(client, number, mediatype, mimetype, .assert_scalar_string(number, "number") .assert_scalar_string(mimetype, "mimetype") .assert_scalar_string(file_name, "file_name") + number <- .normalize_number(number) if (!mediatype %in% c("image", "video", "document")) { cli::cli_abort( '{.arg mediatype} must be one of {.val image}, {.val video}, or {.val document}. Got {.val {mediatype}}.' @@ -383,6 +388,7 @@ send_whatsapp_audio <- function(client, number, audio, delay = NULL, quoted = NULL, verbose = FALSE) { .assert_scalar_string(number, "number") .assert_scalar_string(audio, "audio") + number <- .normalize_number(number) audio_norm <- .normalize_media(audio) body <- list( @@ -413,6 +419,7 @@ send_whatsapp_audio <- function(client, number, audio, delay = NULL, send_sticker <- function(client, number, sticker, delay = NULL, verbose = FALSE) { .assert_scalar_string(number, "number") .assert_scalar_string(sticker, "sticker") + number <- .normalize_number(number) sticker_norm <- .normalize_media(sticker) body <- list(number = number, sticker = sticker_norm, delay = delay) @@ -444,6 +451,7 @@ send_location <- function(client, number, latitude, longitude, if (!is.numeric(latitude) || !is.numeric(longitude)) { cli::cli_abort("{.arg latitude} and {.arg longitude} must be numeric values.") } + number <- .normalize_number(number) body <- list( number = number, @@ -486,6 +494,7 @@ send_location <- function(client, number, latitude, longitude, #' @export send_contact <- function(client, number, contact, verbose = FALSE) { .assert_scalar_string(number, "number") + number <- .normalize_number(number) to_wuid <- function(num) { clean <- gsub("[^0-9]", "", num) @@ -586,6 +595,7 @@ send_buttons <- function(client, number, title, description, footer, buttons, .assert_scalar_string(title, "title") .assert_scalar_string(description, "description") .assert_scalar_string(footer, "footer") + number <- .normalize_number(number) if (!is.list(buttons) || length(buttons) == 0L) { cli::cli_abort("{.arg buttons} must be a non-empty list of button definitions.") } @@ -628,6 +638,7 @@ send_poll <- function(client, number, name, values, selectable_count = 1L, verbose = FALSE) { .assert_scalar_string(number, "number") .assert_scalar_string(name, "name") + number <- .normalize_number(number) if (!is.character(values) || length(values) < 2L) { cli::cli_abort("{.arg values} must be a character vector with at least 2 options.") } @@ -697,6 +708,7 @@ send_list <- function(client, number, title, description, .assert_scalar_string(title, "title") .assert_scalar_string(description, "description") .assert_scalar_string(button_text, "button_text") + number <- .normalize_number(number) if (!is.list(sections) || length(sections) == 0L) { cli::cli_abort("{.arg sections} must be a non-empty list of section definitions.") } @@ -735,6 +747,7 @@ check_is_whatsapp <- function(client, numbers, verbose = FALSE) { if (!is.character(numbers) || length(numbers) == 0L) { cli::cli_abort("{.arg numbers} must be a non-empty character vector of phone numbers.") } + numbers <- vapply(numbers, .normalize_number, character(1L), USE.NAMES = FALSE) body <- list(numbers = as.list(numbers)) .evo_post(client, .evo_path("chat", "whatsappNumbers", client$instance), @@ -753,6 +766,24 @@ check_is_whatsapp <- function(client, numbers, verbose = FALSE) { } } +#' Normalise a recipient number for the API +#' +#' @description Strips formatting characters (spaces, dashes, parentheses and +#' the leading `+`) from a plain phone number so the value sent to the API is +#' digits-only. Strings that already look like a JID (containing `@`, e.g. a +#' group id `...@g.us`) are returned unchanged. +#' @keywords internal +#' @param x A single character string (phone number or JID). +#' @return A normalised character scalar. +.normalize_number <- function(x) { + if (grepl("@", x, fixed = TRUE)) return(x) + cleaned <- gsub("[^0-9]", "", x) + if (!nzchar(cleaned)) { + cli::cli_abort("{.arg number} does not contain any digits: {.val {x}}.") + } + cleaned +} + #' Normalise media input (URL, file path, base64, data-URI) #' @keywords internal #' @param x The media input to normalise. @@ -780,13 +811,27 @@ check_is_whatsapp <- function(client, numbers, verbose = FALSE) { x <- sub("^data:.*;base64,", "", x) } - # Clean whitespace and validate as base64 - x <- gsub("\\s+", "", x) - if (!grepl("^[A-Za-z0-9+/=]+$", x)) { + # Case 4: raw base64. Standard (padded) base64 uses only [A-Za-z0-9+/=], + # has no `.`, and its length is a multiple of 4. Requiring all three avoids + # silently treating a mistyped file path (e.g. "report.pdf") as base64. + x_clean <- gsub("\\s+", "", x) + is_base64 <- nzchar(x_clean) && + grepl("^[A-Za-z0-9+/]+={0,2}$", x_clean) && + nchar(x_clean) %% 4L == 0L + if (is_base64) return(x_clean) + + # Otherwise: not a URL, an existing file, a data-URI, or valid base64. + # When the input looks like a path, point at the resolved location to help + # the user spot a typo instead of failing with a vague base64 error. + if (grepl("[.~/\\\\]", x) || grepl("\\s", x)) { cli::cli_abort(c( - "x" = "{.arg media} does not appear to be a valid URL, file path, or base64 string.", - "i" = "Expected one of: HTTP(S) URL, existing file path, base64 string, or data-URI." + "x" = "{.arg media} looks like a file path, but no file exists there.", + "i" = "Resolved path (with {.code ~} expanded): {.file {path.expand(x)}}.", + "i" = "Pass an existing file path, an HTTP(S) URL, or a base64 string." )) } - x + cli::cli_abort(c( + "x" = "{.arg media} does not appear to be a valid URL, file path, or base64 string.", + "i" = "Expected one of: HTTP(S) URL, existing file path, base64 string, or data-URI." + )) } diff --git a/R/zzz.R b/R/zzz.R index bad2054..eddbb92 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -1,3 +1,7 @@ +# Null-coalescing operator. `%||%` is only part of base R since 4.4.0, but the +# package supports R (>= 4.2.0), so we define it internally to stay portable. +`%||%` <- function(x, y) if (is.null(x)) y else x + .onLoad <- function(libname, pkgname) { if (is.null(getOption("evolution.timeout"))) { options(evolution.timeout = 60) diff --git a/README.Rmd b/README.Rmd index 1f14e63..f1ad081 100644 --- a/README.Rmd +++ b/README.Rmd @@ -23,7 +23,7 @@ knitr::opts_chunk$set( -> R wrapper for [Evolution API v2](https://doc.evolution-api.com) — a lightweight WhatsApp integration API. +> R wrapper for [Evolution Cloud API](https://evoapicloud.com) — a lightweight WhatsApp integration API. ## Overview @@ -200,5 +200,5 @@ Contributions are welcome! Open issues with reproducible examples and sanitised ## License -MIT © 2025 Andre Leite, Hugo Vasconcelos & Diogo Bezerra +MIT © 2025–2026 Andre Leite, Hugo Vasconcelos & Diogo Bezerra See [LICENSE](LICENSE) for details. diff --git a/README.md b/README.md index 49111a3..3101b29 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ -> R wrapper for [Evolution API v2](https://doc.evolution-api.com) — a +> R wrapper for [Evolution Cloud API](https://evoapicloud.com) — a > lightweight WhatsApp integration API. ## Overview @@ -204,5 +204,5 @@ sanitised logs (remove API keys and phone numbers). ## License -MIT © 2025 Andre Leite, Hugo Vasconcelos & Diogo Bezerra See +MIT © 2025–2026 Andre Leite, Hugo Vasconcelos & Diogo Bezerra See [LICENSE](LICENSE) for details. diff --git a/man/dot-normalize_number.Rd b/man/dot-normalize_number.Rd new file mode 100644 index 0000000..87bf1c7 --- /dev/null +++ b/man/dot-normalize_number.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/evolution.R +\name{.normalize_number} +\alias{.normalize_number} +\title{Normalise a recipient number for the API} +\usage{ +.normalize_number(x) +} +\arguments{ +\item{x}{A single character string (phone number or JID).} +} +\value{ +A normalised character scalar. +} +\description{ +Strips formatting characters (spaces, dashes, parentheses and +the leading \code{+}) from a plain phone number so the value sent to the API is +digits-only. Strings that already look like a JID (containing \code{@}, e.g. a +group id \code{...@g.us}) are returned unchanged. +} +\keyword{internal} diff --git a/man/send_buttons.Rd b/man/send_buttons.Rd index 65373f8..9af4870 100644 --- a/man/send_buttons.Rd +++ b/man/send_buttons.Rd @@ -21,7 +21,9 @@ send_buttons( \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{title}{Character. Button message title.} diff --git a/man/send_list.Rd b/man/send_list.Rd index 3557980..1e08543 100644 --- a/man/send_list.Rd +++ b/man/send_list.Rd @@ -20,7 +20,9 @@ send_list( \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{title}{Character. List message title.} diff --git a/man/send_location.Rd b/man/send_location.Rd index e31ffea..a871c14 100644 --- a/man/send_location.Rd +++ b/man/send_location.Rd @@ -18,7 +18,9 @@ send_location( \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{latitude}{Numeric. Latitude coordinate.} diff --git a/man/send_media.Rd b/man/send_media.Rd index 3a7dced..8bb7c82 100644 --- a/man/send_media.Rd +++ b/man/send_media.Rd @@ -21,7 +21,9 @@ send_media( \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{mediatype}{One of \code{"image"}, \code{"video"}, \code{"document"}.} diff --git a/man/send_poll.Rd b/man/send_poll.Rd index 8d9e03b..98eaec6 100644 --- a/man/send_poll.Rd +++ b/man/send_poll.Rd @@ -10,7 +10,9 @@ send_poll(client, number, name, values, selectable_count = 1L, verbose = FALSE) \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{name}{Question text displayed in the poll.} diff --git a/man/send_sticker.Rd b/man/send_sticker.Rd index 5ee9882..5b643cf 100644 --- a/man/send_sticker.Rd +++ b/man/send_sticker.Rd @@ -10,7 +10,9 @@ send_sticker(client, number, sticker, delay = NULL, verbose = FALSE) \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{sticker}{URL, base64-encoded sticker image, or local file path (auto-encoded to base64). Supports \code{~} expansion.} diff --git a/man/send_text.Rd b/man/send_text.Rd index d85e3ae..0660d2e 100644 --- a/man/send_text.Rd +++ b/man/send_text.Rd @@ -20,7 +20,9 @@ send_text( \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{text}{Character. Message body.} diff --git a/man/send_whatsapp_audio.Rd b/man/send_whatsapp_audio.Rd index 3874a53..b44b786 100644 --- a/man/send_whatsapp_audio.Rd +++ b/man/send_whatsapp_audio.Rd @@ -17,7 +17,9 @@ send_whatsapp_audio( \item{client}{An \code{\link[=evo_client]{evo_client()}} object.} \item{number}{Character. Recipient number with country code -(e.g., \code{"5581999990000"} or \code{"+5581999990000"}).} +(e.g., \code{"5581999990000"} or \code{"+5581999990000"}). Formatting characters +(spaces, dashes, parentheses and a leading \code{+}) are stripped before the +request; a JID such as a group id \code{...@g.us} is passed through unchanged.} \item{audio}{URL, base64-encoded audio, or local file path (auto-encoded to base64). Supports \code{~} expansion.} diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 0000000..a33ceaa --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(evolution) + +test_check("evolution") diff --git a/tests/testthat/test-helpers.R b/tests/testthat/test-helpers.R new file mode 100644 index 0000000..fa6e1ed --- /dev/null +++ b/tests/testthat/test-helpers.R @@ -0,0 +1,45 @@ +test_that(".evo_path() joins segments with a single slash", { + expect_equal(.evo_path("message", "sendText", "inst"), "message/sendText/inst") + expect_equal(.evo_path("chat", "whatsappNumbers", "inst"), "chat/whatsappNumbers/inst") +}) + +test_that(".compact() removes only NULL elements", { + expect_equal(.compact(list(a = 1, b = NULL, c = "x")), list(a = 1, c = "x")) + # NA, FALSE and empty strings must be preserved (only NULL is dropped) + expect_equal(.compact(list(a = NA, b = FALSE, c = "")), list(a = NA, b = FALSE, c = "")) + expect_equal(.compact(list()), list()) +}) + +test_that(".assert_scalar_string() accepts a single non-empty string", { + expect_silent(.assert_scalar_string("ok", "x")) +}) + +test_that(".assert_scalar_string() rejects invalid values", { + expect_error(.assert_scalar_string("", "x"), "non-empty character") + expect_error(.assert_scalar_string(c("a", "b"), "x"), "single non-empty") + expect_error(.assert_scalar_string(123, "x"), "single non-empty") + expect_error(.assert_scalar_string(NULL, "x"), "single non-empty") +}) + +test_that(".normalize_number() strips formatting from plain numbers", { + expect_equal(.normalize_number("+55 81 99999-0000"), "5581999990000") + expect_equal(.normalize_number("(81) 99999 0000"), "81999990000") + expect_equal(.normalize_number("5581999990000"), "5581999990000") +}) + +test_that(".normalize_number() passes JIDs through unchanged", { + expect_equal(.normalize_number("120363000000000000@g.us"), + "120363000000000000@g.us") + expect_equal(.normalize_number("5581999990000@s.whatsapp.net"), + "5581999990000@s.whatsapp.net") +}) + +test_that(".normalize_number() errors when no digits remain", { + expect_error(.normalize_number("+"), "does not contain any digits") +}) + +test_that("%||% returns the left side unless it is NULL", { + expect_equal(1 %||% 2, 1) + expect_equal(NULL %||% 2, 2) + expect_equal("" %||% "fallback", "") +}) diff --git a/tests/testthat/test-jid.R b/tests/testthat/test-jid.R new file mode 100644 index 0000000..ff5cb25 --- /dev/null +++ b/tests/testthat/test-jid.R @@ -0,0 +1,17 @@ +test_that("jid() strips non-digits and appends the WhatsApp suffix", { + expect_equal(jid("5581999990000"), "5581999990000@s.whatsapp.net") + expect_equal(jid("+55 81 99999-0000"), "5581999990000@s.whatsapp.net") + expect_equal(jid("(81) 99999 0000"), "81999990000@s.whatsapp.net") +}) + +test_that("jid() is vectorised over the input", { + expect_equal( + jid(c("+5581999990000", "5511988887777")), + c("5581999990000@s.whatsapp.net", "5511988887777@s.whatsapp.net") + ) +}) + +test_that("jid() rejects non-character input", { + expect_error(jid(5581999990000), "must be a character") + expect_error(jid(character(0)), "must be a character") +}) diff --git a/tests/testthat/test-normalize_media.R b/tests/testthat/test-normalize_media.R new file mode 100644 index 0000000..2fc0427 --- /dev/null +++ b/tests/testthat/test-normalize_media.R @@ -0,0 +1,43 @@ +test_that(".normalize_media() returns HTTP(S) URLs unchanged", { + expect_equal(.normalize_media("https://example.com/a.png"), + "https://example.com/a.png") + expect_equal(.normalize_media("HTTP://example.com/a.png"), + "HTTP://example.com/a.png") +}) + +test_that(".normalize_media() encodes existing local files to base64", { + tmp <- tempfile(fileext = ".bin") + writeBin(as.raw(c(1, 2, 3, 4)), tmp) + on.exit(unlink(tmp), add = TRUE) + + out <- suppressMessages(.normalize_media(tmp)) + expect_equal(out, base64enc::base64encode(tmp)) +}) + +test_that(".normalize_media() strips data-URI prefixes", { + raw <- base64enc::base64encode(charToRaw("hello")) + expect_equal(.normalize_media(paste0("data:text/plain;base64,", raw)), raw) +}) + +test_that(".normalize_media() passes through clean base64", { + raw <- base64enc::base64encode(charToRaw("hello world")) + expect_equal(.normalize_media(raw), raw) +}) + +test_that(".normalize_media() rejects clearly invalid input", { + # No path-like characters and not valid base64 -> generic error. + expect_error(.normalize_media("!!!!"), "does not appear to be a valid") + expect_error(.normalize_media(c("a", "b")), "single string") +}) + +test_that(".normalize_media() gives a path-aware error for missing files", { + expect_error(.normalize_media("does-not-exist.pdf"), + "looks like a file path") + expect_error(.normalize_media("~/no/such/file.png"), + "looks like a file path") +}) + +test_that(".normalize_media() does not treat a mistyped path as base64", { + # "report.pdf" matches no base64 (has a dot) and the file is absent. + expect_error(.normalize_media("report.pdf"), "looks like a file path") +}) diff --git a/tests/testthat/test-validation.R b/tests/testthat/test-validation.R new file mode 100644 index 0000000..3734d5d --- /dev/null +++ b/tests/testthat/test-validation.R @@ -0,0 +1,72 @@ +# Argument validation happens before any network call, so these tests run +# fully offline. A fake client is enough to exercise the guard clauses. + +fake_client <- function() { + evo_client(base_url = "https://example.com", api_key = "k", instance = "inst") +} + +test_that("evo_client() validates its arguments", { + expect_error(evo_client("", "k", "i"), "base_url") + expect_error(evo_client("https://x", "", "i"), "api_key") + expect_error(evo_client("https://x", "k", ""), "instance") +}) + +test_that("evo_client() builds a request and prints cleanly", { + cl <- fake_client() + expect_s3_class(cl, "evo_client") + expect_equal(cl$instance, "inst") + expect_invisible(print(cl)) + out <- cli::cli_fmt(print(cl)) + expect_true(any(grepl("Evolution API Client", out))) + expect_true(any(grepl("inst", out))) +}) + +test_that("evo_client() strips trailing slashes from base_url", { + cl <- evo_client("https://example.com///", "k", "inst") + expect_equal(cl$req$url, "https://example.com") +}) + +test_that(".evo_post() rejects a non-client object", { + expect_error(.evo_post(list(), "p", list()), "evo_client") +}) + +test_that("send_text() validates number and text before any request", { + cl <- fake_client() + expect_error(send_text(cl, "", "hi"), "number") + expect_error(send_text(cl, "5581", ""), "text") +}) + +test_that("send_media() validates mediatype", { + cl <- fake_client() + expect_error( + send_media(cl, "5581", "audio", "audio/ogg", media = "https://x/a.ogg", + file_name = "a.ogg"), + "mediatype" + ) +}) + +test_that("send_location() requires numeric coordinates", { + cl <- fake_client() + expect_error(send_location(cl, "5581", latitude = "x", longitude = 1), + "must be numeric") +}) + +test_that("send_poll() requires at least two options", { + cl <- fake_client() + expect_error(send_poll(cl, "5581", name = "Q?", values = "only one"), + "at least 2 options") +}) + +test_that("send_reaction() validates key and reaction", { + cl <- fake_client() + expect_error(send_reaction(cl, key = list(), reaction = "x"), "id") + expect_error( + send_reaction(cl, key = list(id = "1"), reaction = c("a", "b")), + "single character" + ) +}) + +test_that("check_is_whatsapp() requires a non-empty character vector", { + cl <- fake_client() + expect_error(check_is_whatsapp(cl, numbers = character(0)), "non-empty") +}) From 0e96576b36664ed61a78eef78e4886c3995718e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Sun, 14 Jun 2026 13:06:48 -0300 Subject: [PATCH 2/4] Update cran-comments.md for 0.1.1 submission Co-Authored-By: Claude Opus 4.8 (1M context) --- cran-comments.md | 50 ++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index 496fc68..9c00d50 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -1,33 +1,37 @@ -## R CMD check results +## Submission summary + +This is a minor update (0.1.0 -> 0.1.1) that fixes a portability bug and +improves input handling, documentation and test coverage. -0 errors ✔ | 0 warnings ✔ | 0 notes ✔ +Main changes (see NEWS.md for the full list): -## 1. Uwe Ligges +* Fixed a bug where the package could fail to load on R 4.2-4.3: the + null-coalescing operator `%||%` is only part of base R since 4.4.0, but + the package declares `R (>= 4.2.0)`. It is now defined internally. +* Recipient phone numbers are normalised before the request, and the + media helper no longer treats a mistyped file path as base64. +* Corrected the copyright holder in the LICENSE file. +* Added a testthat suite covering the offline helpers and argument + validation. -The following issues reported by CRAN have been corrected: - 1. Title field - • Fixed: The Title field in DESCRIPTION now starts with the package name. - • Old: Evolution API v2 Client for R - • New: A Client for 'Evolution Cloud API' - 2. URL field - • Fixed: The URL field is now a valid list of URLs separated by commas, without invalid tags. +## Test environments -## 2. Konstanze Lauseker +* local macOS, R 4.6.0 +* (please add win-builder / R-hub results here before submitting) -> Please add \value to .Rd files regarding exported methods and explain -> the functions results in the documentation. Please write about the -> structure of the output (class) and also what the output means. (If a -> function does not return a value, please document that too, e.g. -> \value{No return value, called for side effects} or similar) +## R CMD check results -0 errors ✔ | 0 warnings ✔ | 0 notes ✔ +0 errors | 0 warnings | 0 notes -Dear Konstanze, +On the local macOS machine two NOTEs appear that are environment-specific +and do not reflect the package: -Thank you for the review. I have added \\value sections to all affected .Rd files (send_buttons, send_location, send_media, send_poll, send_reaction, send_status, send_sticker, send_whatsapp_audio). -Each now documents the returned object structure (parsed JSON response as an R list with an HTTP status attribute) or states explicitly when a function is called only for side effects. +* "Skipping checking HTML validation: 'tidy' doesn't look like recent + enough HTML Tidy." -- the local HTML Tidy binary is outdated. +* A `.DS_Store` file in the check directory -- created by macOS Finder + inside the `.Rcheck` working directory during the run; it is not part + of the built tarball. -All documentation has been updated for consistency, and the package has been resubmitted. +## Downstream dependencies -Best regards, -André Leite +There are currently no downstream dependencies on CRAN. From 7310aa47a9646e49f5a15583d4427f5e89f932ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Sun, 14 Jun 2026 13:17:45 -0300 Subject: [PATCH 3/4] Add R-CMD-check GitHub Actions workflow Runs R CMD check across macOS, Windows and Linux (devel/release/oldrel-1) on push, PR and manual dispatch. Adds the status badge to the README and ignores .github in the package build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .Rbuildignore | 1 + .github/workflows/R-CMD-check.yaml | 52 ++++++++++++++++++++++++++++++ README.Rmd | 5 +-- README.md | 3 +- 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/R-CMD-check.yaml diff --git a/.Rbuildignore b/.Rbuildignore index 1069d7d..2046aea 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -9,5 +9,6 @@ ^\.Rhistory$ ^\.DS_Store$ ^\.claude$ +^\.github$ ^evolution\.Rcheck$ ^evolution_.*\.tar\.gz$ diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..2b4719b --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,52 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +name: R-CMD-check.yaml + +permissions: read-all + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: macos-latest, r: 'release'} + - {os: windows-latest, r: 'release'} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'oldrel-1'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' diff --git a/README.Rmd b/README.Rmd index f1ad081..eda325a 100644 --- a/README.Rmd +++ b/README.Rmd @@ -18,8 +18,9 @@ knitr::opts_chunk$set( ![CRAN_Status_Badge](https://www.r-pkg.org/badges/version/evolution)  ![CRAN Downloads](https://cranlogs.r-pkg.org/badges/grand-total/evolution)  -![License](https://img.shields.io/badge/license-MIT-darkviolet.svg)  -![](https://img.shields.io/badge/devel%20version-0.1.0-orangered.svg) +![License](https://img.shields.io/badge/license-MIT-darkviolet.svg)  +[![R-CMD-check](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml)  +![](https://img.shields.io/badge/devel%20version-0.1.1-orangered.svg) diff --git a/README.md b/README.md index 3101b29..709c739 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ ![CRAN_Status_Badge](https://www.r-pkg.org/badges/version/evolution)  ![CRAN Downloads](https://cranlogs.r-pkg.org/badges/grand-total/evolution)  ![License](https://img.shields.io/badge/license-MIT-darkviolet.svg)  -![](https://img.shields.io/badge/devel%20version-0.1.0-orangered.svg) +[![R-CMD-check](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml) +![](https://img.shields.io/badge/devel%20version-0.1.1-orangered.svg) From ad37e691bcdd5412e2e691f5038c1afcad771d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Sun, 14 Jun 2026 13:33:11 -0300 Subject: [PATCH 4/4] Record GitHub Actions test environments in cran-comments.md CI is green on Linux (devel/release/oldrel-1), macOS and Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- cran-comments.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index 9c00d50..5f036a6 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -17,14 +17,20 @@ Main changes (see NEWS.md for the full list): ## Test environments * local macOS, R 4.6.0 -* (please add win-builder / R-hub results here before submitting) +* GitHub Actions (R-CMD-check): + * ubuntu-latest, R devel + * ubuntu-latest, R release + * ubuntu-latest, R oldrel-1 + * macos-latest, R release + * windows-latest, R release ## R CMD check results 0 errors | 0 warnings | 0 notes -On the local macOS machine two NOTEs appear that are environment-specific -and do not reflect the package: +On all GitHub Actions environments the check is clean. On the local macOS +machine two additional NOTEs appear that are environment-specific and do +not reflect the package: * "Skipping checking HTML validation: 'tidy' doesn't look like recent enough HTML Tidy." -- the local HTML Tidy binary is outdated.