diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 56e0540..ccedbfb 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -3,7 +3,7 @@ /** * Plugin Name: Common Knowledge Join Flow * Description: Common Knowledge join flow plugin. - * Version: 1.4.37 + * Version: 1.4.39 * Author: Common Knowledge * Text Domain: common-knowledge-join-flow * License: GPLv2 or later diff --git a/packages/join-block/readme.txt b/packages/join-block/readme.txt index 7e28ef7..cb5f3ec 100644 --- a/packages/join-block/readme.txt +++ b/packages/join-block/readme.txt @@ -4,7 +4,7 @@ Tags: membership, subscription, join Contributors: commonknowledgecoop Requires at least: 5.4 Tested up to: 7.0 -Stable tag: 1.4.37 +Stable tag: 1.4.39 Requires PHP: 8.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,8 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled == Changelog == += 1.4.39 = +* Add Zetkin people listing and Zetkin and Mailchimp tag helpers that report status instead of throwing, for bulk maintenance jobs = 1.4.37 = * Add "cancelled" tag option, distinguishing this from lapsed membership = 1.4.36 = diff --git a/packages/join-block/src/Services/MailchimpService.php b/packages/join-block/src/Services/MailchimpService.php index 845edbb..7d1ee59 100644 --- a/packages/join-block/src/Services/MailchimpService.php +++ b/packages/join-block/src/Services/MailchimpService.php @@ -13,6 +13,12 @@ class MailchimpService { + // Outcomes of a tag write, returned by tryAddTag and tryRemoveTag. + public const TAG_OK = 'ok'; + public const TAG_NOT_FOUND = 'not_found'; + public const TAG_NOT_CONFIGURED = 'not_configured'; + public const TAG_ERROR = 'error'; + public static function buildMergeFields(array $data): array { if ($data['isUpdateFlow']) { @@ -146,7 +152,6 @@ public static function signup($data) // For new members, we need to remove tags via updateListMemberTags (can't do it in addListMember) if ($memberExists || !empty($removeTags)) { try { - $subscriberHash = md5(strtolower($email)); $tagUpdates = []; // If member exists, add tags that weren't added during creation @@ -164,11 +169,7 @@ public static function signup($data) } if (!empty($tagUpdates)) { - $mailchimp->lists->updateListMemberTags( - $mailchimp_audience_id, - $subscriberHash, - ["tags" => $tagUpdates] - ); + self::updateMemberTags($email, $tagUpdates, $mailchimp); $joinBlockLog->info("Updated tags for $email in Mailchimp"); } } catch (\GuzzleHttp\Exception\ClientException $e) { @@ -235,11 +236,11 @@ public static function updateMember($email, $mergeFields, $previousEmail = null) * @param string $email * @return bool */ - public static function memberExists($email) + public static function memberExists($email, $client = null) { global $joinBlockLog; - $mailchimp = self::getClient(); + $mailchimp = $client ?? self::getClient(); $mailchimp_audience_id = Settings::get("MAILCHIMP_AUDIENCE_ID"); $subscriberHash = md5(strtolower($email)); @@ -255,57 +256,103 @@ public static function memberExists($email) } } - public static function addTag($email, $tag) + public static function isConfigured() { - global $joinBlockLog; + return !empty(Settings::get("MAILCHIMP_API_KEY")) + && !empty(Settings::get("MAILCHIMP_AUDIENCE_ID")); + } - if (!self::memberExists($email)) { - $joinBlockLog->warning("Skipping Mailchimp addTag('$tag') for $email: member does not exist"); + // The one place a Mailchimp tag write is built. Does not catch: callers + // pick their own error policy. + private static function updateMemberTags($email, array $tagUpdates, $client = null) + { + if (empty($tagUpdates)) { return; } - $mailchimp = self::getClient(); + $client = $client ?? self::getClient(); $mailchimp_audience_id = Settings::get("MAILCHIMP_AUDIENCE_ID"); - $subscriberHash = md5(strtolower($email)); + $client->lists->updateListMemberTags( + $mailchimp_audience_id, + $subscriberHash, + ["tags" => $tagUpdates] + ); + } + + // Reporting counterparts to addTag and removeTag: return a TAG_* status + // rather than throwing, so a bulk run can carry on and account for it. + public static function tryAddTag($email, $tag, $client = null) + { + return self::trySetTag($email, $tag, 'active', $client); + } + + public static function tryRemoveTag($email, $tag, $client = null) + { + return self::trySetTag($email, $tag, 'inactive', $client); + } + + // Mailchimp has no separate remove call; a tag is switched between active + // and inactive. A member missing from the audience comes back as a 404, + // which is reportable rather than a failure. + private static function trySetTag($email, $tag, $status, $client = null) + { + global $joinBlockLog; + + if (!self::isConfigured()) { + return self::TAG_NOT_CONFIGURED; + } + try { - $mailchimp->lists->updateListMemberTags( - $mailchimp_audience_id, - $subscriberHash, - ["tags" => [["name" => $tag, "status" => "active"]]] - ); - $joinBlockLog->info("Added tag '$tag' to $email in Mailchimp"); + self::updateMemberTags($email, [["name" => $tag, "status" => $status]], $client); + return self::TAG_OK; } catch (\GuzzleHttp\Exception\ClientException $e) { - $joinBlockLog->error("Failed to add tag '$tag' to $email in Mailchimp: " . $e->getMessage()); - throw $e; + $response = $e->getResponse(); + $body = $response ? $response->getBody()->getContents() : $e->getMessage(); + + if (($response && $response->getStatusCode() === 404) || str_contains($body, "Resource Not Found")) { + return self::TAG_NOT_FOUND; + } + + $joinBlockLog->error( + "Mailchimp rejected setting tag '$tag' to $status for $email: " . $body + ); + return self::TAG_ERROR; + } catch (\Throwable $e) { + $joinBlockLog->error( + "Could not reach Mailchimp to set tag '$tag' to $status for $email: " . $e->getMessage() + ); + return self::TAG_ERROR; } } - public static function removeTag($email, $tag) + // Throwing counterpart to trySetTag, for callers that want an exception. + private static function setTagOrThrow($email, $tag, $status, $client = null) { global $joinBlockLog; - if (!self::memberExists($email)) { - $joinBlockLog->warning("Skipping Mailchimp removeTag('$tag') for $email: member does not exist"); + if (!self::memberExists($email, $client)) { + $joinBlockLog->warning("Skipping Mailchimp tag update for $email: member does not exist"); return; } - $mailchimp = self::getClient(); - $mailchimp_audience_id = Settings::get("MAILCHIMP_AUDIENCE_ID"); - - $subscriberHash = md5(strtolower($email)); - try { - $mailchimp->lists->updateListMemberTags( - $mailchimp_audience_id, - $subscriberHash, - ["tags" => [["name" => $tag, "status" => "inactive"]]] - ); - $joinBlockLog->info("Removed tag '$tag' from $email in Mailchimp"); + self::updateMemberTags($email, [["name" => $tag, "status" => $status]], $client); + $joinBlockLog->info("Set Mailchimp tag '$tag' to $status for $email"); } catch (\GuzzleHttp\Exception\ClientException $e) { - $joinBlockLog->error("Failed to remove tag '$tag' from $email in Mailchimp: " . $e->getMessage()); + $joinBlockLog->error("Failed to set Mailchimp tag '$tag' to $status for $email: " . $e->getMessage()); throw $e; } } + + public static function addTag($email, $tag, $client = null) + { + self::setTagOrThrow($email, $tag, 'active', $client); + } + + public static function removeTag($email, $tag, $client = null) + { + self::setTagOrThrow($email, $tag, 'inactive', $client); + } } diff --git a/packages/join-block/src/Services/ZetkinService.php b/packages/join-block/src/Services/ZetkinService.php index b05c583..2410839 100644 --- a/packages/join-block/src/Services/ZetkinService.php +++ b/packages/join-block/src/Services/ZetkinService.php @@ -12,6 +12,15 @@ class ZetkinService { + // Outcomes of a tag write, returned by tryAddTagToPerson and + // tryRemoveTagFromPerson. + public const TAG_OK = 'ok'; + public const TAG_MISSING = 'missing'; + public const TAG_NOT_CONFIGURED = 'not_configured'; + public const TAG_ERROR = 'error'; + + private static $zetkinContextOverride = null; + public static function signup($data) { global $joinBlockLog; @@ -159,48 +168,30 @@ private static function addPerson($baseUrl, $orgId, $clientId, $clientSecret, $j $personId = $responseData["data"]["id"]; - $existingTags = self::getTags($baseUrl, $orgId, $accessToken); + $existingTags = self::getTags($client, $baseUrl, $orgId, $accessToken); $addTags[] = "Unconfirmed"; $addTagIds = []; foreach ($addTags as $tag) { - $existingTag = self::findOrCreateTag($baseUrl, $orgId, $existingTags, $tag, $accessToken); + $existingTag = self::findOrCreateTag($client, $baseUrl, $orgId, $existingTags, $tag, $accessToken); $addTagIds[] = $existingTag["id"]; } $removeTagIds = []; foreach ($removeTags as $tag) { - $existingTag = self::findOrCreateTag($baseUrl, $orgId, $existingTags, $tag, $accessToken); + $existingTag = self::findOrCreateTag($client, $baseUrl, $orgId, $existingTags, $tag, $accessToken); $removeTagIds[] = $existingTag["id"]; } foreach ($addTagIds as $tagId) { - $response = $client->request("PUT", "$baseUrl/orgs/$orgId/people/$personId/tags/$tagId", [ - "headers" => [ - "Authorization" => "Bearer {$accessToken}", - "Content-type" => "application/json", - ], - ]); - $responseData = json_decode($response->getBody()->getContents(), true); - if (!empty($responseData["error"])) { - $joinBlockLog->error("Could not tag person: " . json_encode($responseData["error"])); + if (self::putPersonTag($client, $baseUrl, $orgId, $accessToken, $personId, $tagId) !== 'ok') { + $joinBlockLog->error("Could not tag person $personId with tag $tagId in Zetkin"); } } foreach ($removeTagIds as $tagId) { - $response = $client->request("DELETE", "$baseUrl/orgs/$orgId/people/$personId/tags/$tagId", [ - "headers" => [ - "Authorization" => "Bearer {$accessToken}", - "Content-type" => "application/json", - ], - "http_errors" => false - ]); - $responseData = json_decode($response->getBody()->getContents(), true); - if (!empty($responseData["error"])) { - $msg = $responseData["error"]["title"] ?? ""; - if ($msg !== "404 Not Found") { - $joinBlockLog->error("Could not untag person: " . json_encode($responseData["error"])); - } + if (self::deletePersonTag($client, $baseUrl, $orgId, $accessToken, $personId, $tagId) === 'error') { + $joinBlockLog->error("Could not remove tag $tagId from person $personId in Zetkin"); } } } catch (\GuzzleHttp\Exception\RequestException $e) { @@ -220,9 +211,8 @@ private static function addPerson($baseUrl, $orgId, $clientId, $clientSecret, $j } } - private static function getTags($baseUrl, $orgId, $accessToken) + private static function getTags($client, $baseUrl, $orgId, $accessToken) { - $client = new \GuzzleHttp\Client(); $response = $client->request("GET", "$baseUrl/orgs/$orgId/people/tags", [ "headers" => [ "Authorization" => "Bearer {$accessToken}", @@ -237,7 +227,7 @@ private static function getTags($baseUrl, $orgId, $accessToken) return $responseData["data"] ?? []; } - private static function findOrCreateTag($baseUrl, $orgId, $tags, $title, $accessToken) + private static function findOrCreateTag($client, $baseUrl, $orgId, $tags, $title, $accessToken) { $matchingTags = array_filter($tags, function ($tag) use ($title) { return strtolower($tag['title']) === strtolower($title); @@ -247,7 +237,6 @@ private static function findOrCreateTag($baseUrl, $orgId, $tags, $title, $access return array_values($matchingTags)[0]; } - $client = new \GuzzleHttp\Client(); $response = $client->request("POST", "$baseUrl/orgs/$orgId/people/tags", [ "headers" => [ "Authorization" => "Bearer {$accessToken}", @@ -283,6 +272,14 @@ public static function findPersonByEmail($email) return null; } + return self::searchPeopleByEmail($zetkinContext, $email)[0] ?? null; + } + + // The one place Zetkin is searched for a person. Zetkin's search is fuzzy, + // so the results are filtered down to exact email matches. More than one + // can come back, because nothing stops Zetkin holding duplicates. + private static function searchPeopleByEmail($zetkinContext, $email) + { ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; $response = $client->request("POST", "$baseUrl/orgs/$orgId/search/person", [ @@ -299,13 +296,8 @@ public static function findPersonByEmail($email) } $people = $responseData["data"] ?? []; - foreach ($people as $candidate) { - if ($candidate["email"] === $email) { - return $candidate; - } - } - return null; + return array_values(array_filter($people, fn($p) => $p["email"] === $email)); } /** @@ -329,27 +321,7 @@ public static function updatePerson($email, $personData, $previousEmail = null) try { $searchEmail = $previousEmail ?? $email; - $response = $client->request("POST", "$baseUrl/orgs/$orgId/search/person", [ - "headers" => [ - "Authorization" => "Bearer {$accessToken}", - "Content-type" => "application/json", - ], - "json" => ["q" => $searchEmail], - ]); - $responseData = json_decode($response->getBody()->getContents(), true); - - if (!empty($responseData["error"])) { - throw new \Exception(json_encode($responseData["error"])); - } - - $people = $responseData["data"] ?? []; - $person = null; - foreach ($people as $candidate) { - if ($candidate["email"] === $searchEmail) { - $person = $candidate; - break; - } - } + $person = self::searchPeopleByEmail($zetkinContext, $searchEmail)[0] ?? null; if (!$person) { $joinBlockLog->warning("Cannot update person in Zetkin - no person found with email $searchEmail"); @@ -397,10 +369,22 @@ public static function updatePerson($email, $personData, $previousEmail = null) } } + // Test seam: getZetkinContext() performs a live OAuth exchange, which + // makes everything built on it untestable. Pass null to restore normal + // behaviour. + public static function overrideZetkinContext($zetkinContext) + { + self::$zetkinContextOverride = $zetkinContext; + } + private static function getZetkinContext() { global $joinBlockLog; + if (self::$zetkinContextOverride !== null) { + return self::$zetkinContextOverride; + } + $clientId = Settings::get("ZETKIN_CLIENT_ID"); $clientSecret = Settings::get("ZETKIN_CLIENT_SECRET"); $jwt = Settings::get("ZETKIN_JWT"); @@ -425,70 +409,169 @@ private static function getZetkinContext() ]; } - /** - * Standalone function to find a person by email and apply a tag (string) - */ - public static function addTag($email, $tag) + // Zetkin pages with p (zero-indexed) and pp (page size); an empty array + // means the end of the list. Each call opens its own Zetkin context, so a + // full walk costs one OAuth exchange per page. + public static function listPeople($page = 0, $perPage = 100) { - global $joinBlockLog; - try { - $zetkinContext = self::getZetkinContext(); - if (!$zetkinContext) { - return; - } + $zetkinContext = self::getZetkinContext(); + if (!$zetkinContext) { + return []; + } - ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; + ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; - $response = $client->request("POST", "$baseUrl/orgs/$orgId/search/person", [ - "headers" => [ - "Authorization" => "Bearer {$accessToken}", - "Content-type" => "application/json", - ], - "json" => [ - "q" => $email, - ] - ]); - $responseData = json_decode($response->getBody()->getContents(), true); + $response = $client->request("GET", "$baseUrl/orgs/$orgId/people?p=$page&pp=$perPage", [ + "headers" => [ + "Authorization" => "Bearer {$accessToken}", + "Content-type" => "application/json", + ] + ]); + $responseData = json_decode($response->getBody()->getContents(), true); - if (!empty($responseData["error"])) { - throw new \Exception(json_encode($responseData["error"])); - } + if (!empty($responseData["error"])) { + throw new \Exception("Could not list people in Zetkin: " . json_encode($responseData["error"])); + } - $people = $responseData["data"] ?? []; - $matched = array_filter($people, fn($p) => $p["email"] === $email); - if (empty($matched)) { - $joinBlockLog->warning("Could not add tag '$tag' in Zetkin: no person found for $email"); - return; - } - $existingTags = self::getTags($baseUrl, $orgId, $accessToken); - $existingTag = self::findOrCreateTag($baseUrl, $orgId, $existingTags, $tag, $accessToken); - foreach ($matched as $person) { - $personId = $person["id"]; - $tagId = $existingTag["id"]; - $response = $client->request("PUT", "$baseUrl/orgs/$orgId/people/$personId/tags/$tagId", [ - "headers" => [ - "Authorization" => "Bearer {$accessToken}", - "Content-type" => "application/json", - ] - ]); - $responseData = json_decode($response->getBody()->getContents(), true); - if (!empty($responseData["error"])) { - $joinBlockLog->error("Could not add tag '$tag' to $email in Zetkin: " . json_encode($responseData["error"])); - } else { - $joinBlockLog->info("Added tag '$tag' to $email in Zetkin"); - } - } - } catch (\Exception $e) { - $joinBlockLog->error("Could not add tag '$tag' to $email in Zetkin: " . $e->getMessage()); + return $responseData["data"] ?? []; + } + + public static function getPersonTags($personId) + { + $zetkinContext = self::getZetkinContext(); + if (!$zetkinContext) { + return []; + } + + ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; + + $response = $client->request("GET", "$baseUrl/orgs/$orgId/people/$personId/tags", [ + "headers" => [ + "Authorization" => "Bearer {$accessToken}", + "Content-type" => "application/json", + ] + ]); + $responseData = json_decode($response->getBody()->getContents(), true); + + if (!empty($responseData["error"])) { + throw new \Exception("Could not get tags for person $personId in Zetkin: " . json_encode($responseData["error"])); + } + + return $responseData["data"] ?? []; + } + + // Public wrapper over the same find-or-create the signup path uses, so + // bulk jobs tag people with exactly the tags a signup would. + public static function findOrCreateTagByTitle($title) + { + $zetkinContext = self::getZetkinContext(); + if (!$zetkinContext) { + return null; + } + + ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; + + $existingTags = self::getTags($client, $baseUrl, $orgId, $accessToken); + + return self::findOrCreateTag($client, $baseUrl, $orgId, $existingTags, $title, $accessToken); + } + + // Both report one of the TAG_* constants, mirroring MailchimpService. + public static function tryAddTagToPerson($personId, $tagId) + { + $zetkinContext = self::getZetkinContext(); + if (!$zetkinContext) { + return self::TAG_NOT_CONFIGURED; + } + + ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; + + return self::putPersonTag($client, $baseUrl, $orgId, $accessToken, $personId, $tagId) === 'ok' + ? self::TAG_OK + : self::TAG_ERROR; + } + + // A tag the person does not have comes back as TAG_MISSING, distinct from + // both success and failure; callers decide what it means to them. + public static function tryRemoveTagFromPerson($personId, $tagId) + { + $zetkinContext = self::getZetkinContext(); + if (!$zetkinContext) { + return self::TAG_NOT_CONFIGURED; + } + + ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; + + $result = self::deletePersonTag($client, $baseUrl, $orgId, $accessToken, $personId, $tagId); + + if ($result === 'missing') { + return self::TAG_MISSING; + } + + return $result === 'ok' ? self::TAG_OK : self::TAG_ERROR; + } + + // Single implementation of "apply this tag to this person". + // Returns 'ok' or 'error'; callers add their own context to the log. + private static function putPersonTag($client, $baseUrl, $orgId, $accessToken, $personId, $tagId) + { + $response = $client->request("PUT", "$baseUrl/orgs/$orgId/people/$personId/tags/$tagId", [ + "headers" => [ + "Authorization" => "Bearer {$accessToken}", + "Content-type" => "application/json", + ] + ]); + $responseData = json_decode($response->getBody()->getContents(), true); + + return empty($responseData["error"]) ? 'ok' : 'error'; + } + + // Single implementation of "take this tag off this person". + // Returns 'ok', 'missing' when the person did not have the tag, or 'error'. + private static function deletePersonTag($client, $baseUrl, $orgId, $accessToken, $personId, $tagId) + { + $response = $client->request("DELETE", "$baseUrl/orgs/$orgId/people/$personId/tags/$tagId", [ + "headers" => [ + "Authorization" => "Bearer {$accessToken}", + "Content-type" => "application/json", + ], + "http_errors" => false + ]); + $statusCode = $response->getStatusCode(); + + if ($statusCode === 404) { + return 'missing'; } + + return $statusCode >= 400 ? 'error' : 'ok'; + } + + /** + * Standalone function to find a person by email and apply a tag (string) + */ + public static function addTag($email, $tag) + { + self::setTagByEmail($email, $tag, false); } /** * Standalone function to find a person by email and remove a tag (string) */ public static function removeTag($email, $tag) + { + self::setTagByEmail($email, $tag, true); + } + + // Resolves the person and the tag title, then applies the change to every + // exact email match. Logs and swallows rather than throwing, which is what + // both callers of addTag and removeTag relied on. + private static function setTagByEmail($email, $tag, $remove) { global $joinBlockLog; + + $verb = $remove ? 'remove' : 'add'; + $done = $remove ? 'removed from' : 'added to'; + try { $zetkinContext = self::getZetkinContext(); if (!$zetkinContext) { @@ -497,51 +580,30 @@ public static function removeTag($email, $tag) ['baseUrl' => $baseUrl, 'orgId' => $orgId, 'accessToken' => $accessToken, 'client' => $client] = $zetkinContext; - $response = $client->request("POST", "$baseUrl/orgs/$orgId/search/person", [ - "headers" => [ - "Authorization" => "Bearer {$accessToken}", - "Content-type" => "application/json", - ], - "json" => [ - "q" => $email, - ] - ]); - $responseData = json_decode($response->getBody()->getContents(), true); - - if (!empty($responseData["error"])) { - throw new \Exception(json_encode($responseData["error"])); - } - - $people = $responseData["data"] ?? []; - $matched = array_filter($people, fn($p) => $p["email"] === $email); + $matched = self::searchPeopleByEmail($zetkinContext, $email); if (empty($matched)) { - $joinBlockLog->warning("Could not remove tag '$tag' in Zetkin: no person found for $email"); + $joinBlockLog->warning("Could not $verb tag '$tag' in Zetkin: no person found for $email"); return; } - $existingTags = self::getTags($baseUrl, $orgId, $accessToken); - $existingTag = self::findOrCreateTag($baseUrl, $orgId, $existingTags, $tag, $accessToken); + + $existingTags = self::getTags($client, $baseUrl, $orgId, $accessToken); + $existingTag = self::findOrCreateTag($client, $baseUrl, $orgId, $existingTags, $tag, $accessToken); + foreach ($matched as $person) { - $personId = $person["id"]; - $tagId = $existingTag["id"]; - $response = $client->request("DELETE", "$baseUrl/orgs/$orgId/people/$personId/tags/$tagId", [ - "headers" => [ - "Authorization" => "Bearer {$accessToken}", - "Content-type" => "application/json", - ], - "http_errors" => false - ]); - $statusCode = $response->getStatusCode(); - if ($statusCode === 404) { - $joinBlockLog->info("Could not remove tag '$tag' from $email in Zetkin: tag does not exist"); - } elseif ($statusCode >= 400) { - $responseData = json_decode($response->getBody()->getContents(), true); - $joinBlockLog->error("Could not remove tag '$tag' from $email in Zetkin: " . json_encode($responseData["error"] ?? $statusCode)); + $result = $remove + ? self::deletePersonTag($client, $baseUrl, $orgId, $accessToken, $person["id"], $existingTag["id"]) + : self::putPersonTag($client, $baseUrl, $orgId, $accessToken, $person["id"], $existingTag["id"]); + + if ($result === 'error') { + $joinBlockLog->error("Could not $verb tag '$tag' for $email in Zetkin"); + } elseif ($result === 'missing') { + $joinBlockLog->info("Tag '$tag' was not on $email in Zetkin"); } else { - $joinBlockLog->info("Removed tag '$tag' from $email in Zetkin"); + $joinBlockLog->info("Tag '$tag' $done $email in Zetkin"); } } } catch (\Exception $e) { - $joinBlockLog->error("Could not remove tag '$tag' from $email in Zetkin: " . $e->getMessage()); + $joinBlockLog->error("Could not $verb tag '$tag' for $email in Zetkin: " . $e->getMessage()); } } diff --git a/packages/join-block/tests/MailchimpServiceLegacyTagsTest.php b/packages/join-block/tests/MailchimpServiceLegacyTagsTest.php new file mode 100644 index 0000000..be8fe96 --- /dev/null +++ b/packages/join-block/tests/MailchimpServiceLegacyTagsTest.php @@ -0,0 +1,192 @@ +justReturn(null); + + $_ENV['MAILCHIMP_API_KEY'] = 'test-key-us1'; + $_ENV['MAILCHIMP_AUDIENCE_ID'] = 'test-audience'; + + global $joinBlockLog; + $this->logger = new class { + public array $infos = []; + public array $warnings = []; + public array $errors = []; + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function info(string $msg, array $ctx = []): void + { + $this->infos[] = $msg; + } + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function warning(string $msg, array $ctx = []): void + { + $this->warnings[] = $msg; + } + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function error(string $msg, array $ctx = []): void + { + $this->errors[] = $msg; + } + }; + $joinBlockLog = $this->logger; + } + + protected function tearDown(): void + { + global $joinBlockLog; + $joinBlockLog = null; + unset($_ENV['MAILCHIMP_API_KEY'], $_ENV['MAILCHIMP_AUDIENCE_ID']); + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * A client whose member lookup and tag update can each be told to fail. + */ + private function fakeClient(bool $memberExists = true, ?\Throwable $throwOnUpdate = null): object + { + $lists = new class ($memberExists, $throwOnUpdate) { + public array $updates = []; + public array $lookups = []; + + public function __construct(private bool $memberExists, private ?\Throwable $throwOnUpdate) + { + } + + public function getListMember($audienceId, $subscriberHash) + { + $this->lookups[] = [$audienceId, $subscriberHash]; + if (!$this->memberExists) { + throw new ClientException( + 'HTTP 404', + new Request('GET', '/'), + new Response(404, [], '{"title":"Resource Not Found","status":404}') + ); + } + return ['id' => $subscriberHash]; + } + + public function updateListMemberTags($audienceId, $subscriberHash, $body) + { + $this->updates[] = ['audienceId' => $audienceId, 'hash' => $subscriberHash, 'body' => $body]; + if ($this->throwOnUpdate) { + throw $this->throwOnUpdate; + } + return null; + } + }; + + return new class ($lists) { + public $lists; + + public function __construct($lists) + { + $this->lists = $lists; + } + }; + } + + public function test_add_tag_sends_the_tag_as_active() + { + $client = $this->fakeClient(); + + MailchimpService::addTag('person@example.com', 'Bury', $client); + + $this->assertCount(1, $client->lists->updates); + $this->assertSame( + [['name' => 'Bury', 'status' => 'active']], + $client->lists->updates[0]['body']['tags'] + ); + $this->assertSame([], $this->logger->errors); + } + + public function test_remove_tag_sends_the_tag_as_inactive() + { + $client = $this->fakeClient(); + + MailchimpService::removeTag('person@example.com', 'Bury', $client); + + $this->assertSame( + [['name' => 'Bury', 'status' => 'inactive']], + $client->lists->updates[0]['body']['tags'] + ); + } + + /** + * A member who is not in the audience is a warning and a no-op, never an + * API call or an exception. Both callers depend on that. + */ + public function test_add_tag_skips_a_member_who_is_not_in_the_audience() + { + $client = $this->fakeClient(memberExists: false); + + MailchimpService::addTag('ghost@example.com', 'Bury', $client); + + $this->assertSame([], $client->lists->updates); + $this->assertStringContainsString('member does not exist', $this->logger->warnings[0]); + } + + /** + * The exception that reaches the caller must be the original, not a + * re-wrap. External code catching ClientException relies on it. + */ + public function test_add_tag_rethrows_the_original_exception() + { + $original = new ClientException( + 'HTTP 403', + new Request('POST', '/'), + new Response(403, [], '{"title":"Forbidden"}') + ); + $client = $this->fakeClient(throwOnUpdate: $original); + + $caught = null; + try { + MailchimpService::addTag('person@example.com', 'Bury', $client); + } catch (\Throwable $e) { + $caught = $e; + } + + $this->assertSame($original, $caught); + $this->assertNotEmpty($this->logger->errors); + } + + public function test_member_exists_is_true_for_a_known_member() + { + $this->assertTrue(MailchimpService::memberExists('person@example.com', $this->fakeClient())); + } + + public function test_member_exists_is_false_for_a_404() + { + $this->assertFalse(MailchimpService::memberExists('ghost@example.com', $this->fakeClient(memberExists: false))); + } +} diff --git a/packages/join-block/tests/MailchimpServiceTagsTest.php b/packages/join-block/tests/MailchimpServiceTagsTest.php new file mode 100644 index 0000000..627ab61 --- /dev/null +++ b/packages/join-block/tests/MailchimpServiceTagsTest.php @@ -0,0 +1,249 @@ +justReturn(null); + + $_ENV['MAILCHIMP_API_KEY'] = 'test-key-us1'; + $_ENV['MAILCHIMP_AUDIENCE_ID'] = 'test-audience'; + + global $joinBlockLog; + $joinBlockLog = new class { + public array $errors = []; + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function info(string $msg, array $ctx = []): void {} + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function warning(string $msg, array $ctx = []): void {} + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function error(string $msg, array $ctx = []): void + { + $this->errors[] = $msg; + } + }; + } + + protected function tearDown(): void + { + global $joinBlockLog; + $joinBlockLog = null; + unset($_ENV['MAILCHIMP_API_KEY'], $_ENV['MAILCHIMP_AUDIENCE_ID']); + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * A client that records the calls made to it and returns a canned result. + */ + private function fakeClient(?\Throwable $throw = null): object + { + $lists = new class ($throw) { + public array $calls = []; + private ?\Throwable $throw; + + public function __construct(?\Throwable $throw) + { + $this->throw = $throw; + } + + public function updateListMemberTags($audienceId, $subscriberHash, $body) + { + $this->calls[] = compact('audienceId', 'subscriberHash', 'body'); + if ($this->throw) { + throw $this->throw; + } + return null; + } + }; + + return new class ($lists) { + public $lists; + + public function __construct($lists) + { + $this->lists = $lists; + } + }; + } + + private function clientException(int $status, string $body): ClientException + { + return new ClientException( + "HTTP $status", + new Request('POST', '/'), + new Response($status, [], $body) + ); + } + + public function testAddTagToMemberReportsOk(): void + { + $client = $this->fakeClient(); + + $result = MailchimpService::tryAddTag('person@example.com', 'Bury', $client); + + $this->assertSame(MailchimpService::TAG_OK, $result); + } + + public function testAddTagToMemberSendsTheTagAsActive(): void + { + $client = $this->fakeClient(); + + MailchimpService::tryAddTag('person@example.com', 'Bury', $client); + + $call = $client->lists->calls[0]; + $this->assertSame([['name' => 'Bury', 'status' => 'active']], $call['body']['tags']); + } + + public function testRemoveTagFromMemberSendsTheTagAsInactive(): void + { + $client = $this->fakeClient(); + + $result = MailchimpService::tryRemoveTag('person@example.com', 'South Manchester', $client); + + $this->assertSame(MailchimpService::TAG_OK, $result); + $call = $client->lists->calls[0]; + $this->assertSame( + [['name' => 'South Manchester', 'status' => 'inactive']], + $call['body']['tags'] + ); + } + + /** + * Mailchimp addresses a member by the md5 of their lowercased email. + * Getting this wrong tags the wrong person, or nobody, without erroring. + */ + public function testMemberIsAddressedByLowercasedEmailHash(): void + { + $client = $this->fakeClient(); + + MailchimpService::tryAddTag('Person@Example.COM', 'Bury', $client); + + $call = $client->lists->calls[0]; + $this->assertSame(md5('person@example.com'), $call['subscriberHash']); + $this->assertSame('test-audience', $call['audienceId']); + } + + /** + * Someone in Zetkin but not in the Mailchimp audience is an expected, + * reportable outcome, not a failure of the run. + */ + public function testUnknownMemberIsReportedAsNotFound(): void + { + $client = $this->fakeClient($this->clientException(404, '{"title":"Resource Not Found"}')); + + $result = MailchimpService::tryAddTag('ghost@example.com', 'Bury', $client); + + $this->assertSame(MailchimpService::TAG_NOT_FOUND, $result); + } + + public function testUnknownMemberIsReportedAsNotFoundWhenRemoving(): void + { + $client = $this->fakeClient($this->clientException(404, '{"title":"Resource Not Found"}')); + + $result = MailchimpService::tryRemoveTag('ghost@example.com', 'Bury', $client); + + $this->assertSame(MailchimpService::TAG_NOT_FOUND, $result); + } + + /** + * Anything else from Mailchimp is an error. It must not be swallowed as + * success, and it must not be mistaken for a missing member. + */ + public function testOtherClientErrorsAreReportedAsError(): void + { + $client = $this->fakeClient($this->clientException(403, '{"title":"API Key Invalid"}')); + + $result = MailchimpService::tryAddTag('person@example.com', 'Bury', $client); + + $this->assertSame(MailchimpService::TAG_ERROR, $result); + } + + public function testUnexpectedFailuresAreReportedAsError(): void + { + $client = $this->fakeClient(new \RuntimeException('connection reset')); + + $result = MailchimpService::tryAddTag('person@example.com', 'Bury', $client); + + $this->assertSame(MailchimpService::TAG_ERROR, $result); + } + + /** + * A bulk job must be able to ask whether Mailchimp is worth talking to + * before it starts, rather than discovering it per member. + */ + public function testIsConfiguredIsTrueWhenKeyAndAudienceAreSet(): void + { + $this->assertTrue(MailchimpService::isConfigured()); + } + + public function testIsConfiguredIsFalseWithoutAnApiKey(): void + { + unset($_ENV['MAILCHIMP_API_KEY']); + + $this->assertFalse(MailchimpService::isConfigured()); + } + + public function testIsConfiguredIsFalseWithoutAnAudience(): void + { + unset($_ENV['MAILCHIMP_AUDIENCE_ID']); + + $this->assertFalse(MailchimpService::isConfigured()); + } + + /** + * The GMTU add-on compares against these values across a plugin boundary, + * and its test fakes return them as literals. Renaming a constant is free; + * changing its value is not, so pin the wire values here. + */ + public function testStatusValuesAreStable(): void + { + $this->assertSame('ok', MailchimpService::TAG_OK); + $this->assertSame('not_found', MailchimpService::TAG_NOT_FOUND); + $this->assertSame('not_configured', MailchimpService::TAG_NOT_CONFIGURED); + $this->assertSame('error', MailchimpService::TAG_ERROR); + } + + /** + * With Mailchimp switched off, the helpers say so rather than + * constructing a client against an empty key. + */ + public function testTaggingIsSkippedWhenMailchimpIsNotConfigured(): void + { + unset($_ENV['MAILCHIMP_API_KEY']); + $client = $this->fakeClient(); + + $result = MailchimpService::tryAddTag('person@example.com', 'Bury', $client); + + $this->assertSame(MailchimpService::TAG_NOT_CONFIGURED, $result); + $this->assertSame([], $client->lists->calls); + } +} diff --git a/packages/join-block/tests/ZetkinServiceTagsTest.php b/packages/join-block/tests/ZetkinServiceTagsTest.php new file mode 100644 index 0000000..295b098 --- /dev/null +++ b/packages/join-block/tests/ZetkinServiceTagsTest.php @@ -0,0 +1,416 @@ +justReturn(null); + + global $joinBlockLog; + $this->logger = new class { + public array $infos = []; + public array $warnings = []; + public array $errors = []; + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function info(string $msg, array $ctx = []): void + { + $this->infos[] = $msg; + } + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function warning(string $msg, array $ctx = []): void + { + $this->warnings[] = $msg; + } + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function error(string $msg, array $ctx = []): void + { + $this->errors[] = $msg; + } + }; + $joinBlockLog = $this->logger; + } + + protected function tearDown(): void + { + ZetkinService::overrideZetkinContext(null); + global $joinBlockLog; + $joinBlockLog = null; + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * A client that routes requests by method and URL fragment, recording + * everything it is asked for. + */ + private function fakeZetkin(array $routes): object + { + $client = new class ($routes) { + public array $requests = []; + private array $routes; + + public function __construct(array $routes) + { + $this->routes = $routes; + } + + public function request($method, $url, $options = []) + { + $this->requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; + foreach ($this->routes as [$routeMethod, $needle, $response]) { + if ($routeMethod === $method && str_contains($url, $needle)) { + return $response; + } + } + throw new \RuntimeException("Unrouted request in test: $method $url"); + } + }; + + ZetkinService::overrideZetkinContext([ + 'baseUrl' => 'https://zetkin.test/v1', + 'orgId' => 99, + 'accessToken' => 'test-token', + 'client' => $client, + ]); + + return $client; + } + + private function response(array $json, int $status = 200): object + { + $body = new class (json_encode($json)) { + public function __construct(private string $contents) + { + } + + public function getContents(): string + { + return $this->contents; + } + }; + + return new class ($body, $status) { + public function __construct(private object $body, private int $status) + { + } + + public function getBody(): object + { + return $this->body; + } + + public function getStatusCode(): int + { + return $this->status; + } + }; + } + + private function requestsMatching(object $client, string $method, string $needle): array + { + return array_values(array_filter( + $client->requests, + fn($r) => $r['method'] === $method && str_contains($r['url'], $needle) + )); + } + + // addTag / removeTag, the email-based pair used by joins and webhooks. + + /** + * Nothing stops Zetkin holding two people with the same email, and addTag + * has always tagged all of them, not the first. Pinned because collapsing + * the search into findPersonByEmail (which takes the first) would silently + * halve its reach. + */ + public function test_add_tag_tags_every_person_with_that_email() + { + $client = $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => [ + ['id' => 5, 'email' => 'a@example.com'], + ['id' => 6, 'email' => 'a@example.com'], + ]])], + ['GET', 'people/tags', $this->response(['data' => [['id' => 7, 'title' => 'Bury']]])], + ['PUT', '/tags/7', $this->response([])], + ]); + + ZetkinService::addTag('a@example.com', 'Bury'); + + $puts = $this->requestsMatching($client, 'PUT', '/tags/7'); + $this->assertCount(2, $puts); + $this->assertStringContainsString('/people/5/tags/7', $puts[0]['url']); + $this->assertStringContainsString('/people/6/tags/7', $puts[1]['url']); + } + + /** + * Zetkin's search is fuzzy. A near-miss email must never be tagged. + */ + public function test_add_tag_ignores_fuzzy_search_matches() + { + $client = $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => [ + ['id' => 5, 'email' => 'a@example.com'], + ['id' => 9, 'email' => 'aa@example.com'], + ]])], + ['GET', 'people/tags', $this->response(['data' => [['id' => 7, 'title' => 'Bury']]])], + ['PUT', '/tags/7', $this->response([])], + ]); + + ZetkinService::addTag('a@example.com', 'Bury'); + + $puts = $this->requestsMatching($client, 'PUT', '/tags/'); + $this->assertCount(1, $puts); + $this->assertStringContainsString('/people/5/', $puts[0]['url']); + } + + public function test_add_tag_warns_and_writes_nothing_when_nobody_matches() + { + $client = $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => []])], + ]); + + ZetkinService::addTag('ghost@example.com', 'Bury'); + + $this->assertCount(1, $client->requests); + $this->assertStringContainsString('no person found for ghost@example.com', $this->logger->warnings[0]); + } + + /** + * addTag has always swallowed API failures: a Zetkin outage must not break + * the join or webhook that called it. It logs and returns. + */ + public function test_add_tag_logs_and_does_not_throw_on_an_api_error() + { + $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['error' => 'nope'])], + ]); + + ZetkinService::addTag('a@example.com', 'Bury'); + + $this->assertNotEmpty($this->logger->errors); + $this->assertStringContainsString('Could not add tag', $this->logger->errors[0]); + } + + public function test_remove_tag_deletes_the_resolved_tag() + { + $client = $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => [['id' => 5, 'email' => 'a@example.com']]])], + ['GET', 'people/tags', $this->response(['data' => [['id' => 7, 'title' => 'Bury']]])], + ['DELETE', '/tags/7', $this->response([])], + ]); + + ZetkinService::removeTag('a@example.com', 'Bury'); + + $deletes = $this->requestsMatching($client, 'DELETE', '/people/5/tags/7'); + $this->assertCount(1, $deletes); + } + + /** + * Removing a tag the person does not have is a non-event, not an error. + */ + public function test_remove_tag_treats_a_missing_tag_as_a_non_event() + { + $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => [['id' => 5, 'email' => 'a@example.com']]])], + ['GET', 'people/tags', $this->response(['data' => [['id' => 7, 'title' => 'Bury']]])], + ['DELETE', '/tags/7', $this->response([], 404)], + ]); + + ZetkinService::removeTag('a@example.com', 'Bury'); + + $this->assertSame([], $this->logger->errors); + $this->assertStringContainsString("was not on", $this->logger->infos[0]); + } + + // Person lookup, shared by addTag, removeTag, updatePerson and + // findPersonByEmail. + + public function test_find_person_by_email_returns_the_first_exact_match() + { + $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => [ + ['id' => 9, 'email' => 'other@example.com'], + ['id' => 5, 'email' => 'a@example.com'], + ['id' => 6, 'email' => 'a@example.com'], + ]])], + ]); + + $person = ZetkinService::findPersonByEmail('a@example.com'); + + $this->assertSame(5, $person['id']); + } + + public function test_find_person_by_email_returns_null_when_nobody_matches() + { + $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => [['id' => 9, 'email' => 'other@example.com']]])], + ]); + + $this->assertNull(ZetkinService::findPersonByEmail('a@example.com')); + } + + public function test_update_person_patches_the_matched_person() + { + $client = $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => [['id' => 5, 'email' => 'a@example.com']]])], + ['PATCH', '/people/5', $this->response(['data' => ['id' => 5]])], + ]); + + ZetkinService::updatePerson('a@example.com', ['first_name' => 'Test']); + + $patches = $this->requestsMatching($client, 'PATCH', '/people/5'); + $this->assertCount(1, $patches); + $this->assertSame(['first_name' => 'Test'], $patches[0]['options']['json']); + } + + public function test_update_person_warns_and_writes_nothing_when_nobody_matches() + { + $client = $this->fakeZetkin([ + ['POST', 'search/person', $this->response(['data' => []])], + ]); + + ZetkinService::updatePerson('ghost@example.com', ['first_name' => 'Test']); + + $this->assertCount(1, $client->requests); + $this->assertStringContainsString('no person found', $this->logger->warnings[0]); + } + + // The bulk helpers the re-tag job is built on. + + public function test_list_people_sends_zetkins_paging_parameters() + { + $client = $this->fakeZetkin([ + ['GET', '/people?', $this->response(['data' => [['id' => 1]]])], + ]); + + $people = ZetkinService::listPeople(2, 50); + + $this->assertSame([['id' => 1]], $people); + $this->assertStringContainsString('/people?p=2&pp=50', $client->requests[0]['url']); + } + + public function test_list_people_returns_an_empty_array_when_exhausted() + { + $this->fakeZetkin([ + ['GET', '/people?', $this->response(['data' => []])], + ]); + + $this->assertSame([], ZetkinService::listPeople(7)); + } + + public function test_get_person_tags_returns_the_tag_records() + { + $this->fakeZetkin([ + ['GET', '/people/5/tags', $this->response(['data' => [['id' => 7, 'title' => 'Bury']]])], + ]); + + $this->assertSame([['id' => 7, 'title' => 'Bury']], ZetkinService::getPersonTags(5)); + } + + // The try* pair report one of the TAG_* constants, mirroring + // MailchimpService, so the re-tag job reads both services the same way. + + public function test_try_add_tag_to_person_reports_ok() + { + $this->fakeZetkin([ + ['PUT', '/people/5/tags/7', $this->response([])], + ]); + + $this->assertSame(ZetkinService::TAG_OK, ZetkinService::tryAddTagToPerson(5, 7)); + } + + public function test_try_add_tag_to_person_reports_an_api_error() + { + $this->fakeZetkin([ + ['PUT', '/people/5/tags/7', $this->response(['error' => 'nope'])], + ]); + + $this->assertSame(ZetkinService::TAG_ERROR, ZetkinService::tryAddTagToPerson(5, 7)); + } + + public function test_try_remove_tag_from_person_reports_ok() + { + $this->fakeZetkin([ + ['DELETE', '/people/5/tags/7', $this->response([])], + ]); + + $this->assertSame(ZetkinService::TAG_OK, ZetkinService::tryRemoveTagFromPerson(5, 7)); + } + + /** + * A tag the person does not have is reported as missing, distinct from + * both success and failure, and callers decide what it means to them. + */ + public function test_try_remove_tag_from_person_reports_a_missing_tag() + { + $this->fakeZetkin([ + ['DELETE', '/people/5/tags/7', $this->response([], 404)], + ]); + + $this->assertSame(ZetkinService::TAG_MISSING, ZetkinService::tryRemoveTagFromPerson(5, 7)); + } + + public function test_try_remove_tag_from_person_reports_an_api_error() + { + $this->fakeZetkin([ + ['DELETE', '/people/5/tags/7', $this->response([], 500)], + ]); + + $this->assertSame(ZetkinService::TAG_ERROR, ZetkinService::tryRemoveTagFromPerson(5, 7)); + } + + public function test_try_helpers_report_not_configured_without_credentials() + { + $this->assertSame(ZetkinService::TAG_NOT_CONFIGURED, ZetkinService::tryAddTagToPerson(5, 7)); + $this->assertSame(ZetkinService::TAG_NOT_CONFIGURED, ZetkinService::tryRemoveTagFromPerson(5, 7)); + } + + /** + * The GMTU add-on compares against these values across a plugin boundary, + * and its test fakes return them as literals. Renaming a constant is free; + * changing its value is not, so pin the wire values here. + */ + public function test_status_values_are_stable() + { + $this->assertSame('ok', ZetkinService::TAG_OK); + $this->assertSame('missing', ZetkinService::TAG_MISSING); + $this->assertSame('not_configured', ZetkinService::TAG_NOT_CONFIGURED); + $this->assertSame('error', ZetkinService::TAG_ERROR); + } + + public function test_find_or_create_tag_by_title_reuses_an_existing_tag() + { + $client = $this->fakeZetkin([ + ['GET', 'people/tags', $this->response(['data' => [['id' => 7, 'title' => 'Bury']]])], + ]); + + $tag = ZetkinService::findOrCreateTagByTitle('bury'); + + $this->assertSame(7, $tag['id']); + $this->assertSame([], $this->requestsMatching($client, 'POST', 'people/tags')); + } +} diff --git a/packages/join-flow/src/index.tsx b/packages/join-flow/src/index.tsx index fc695b1..1b228e2 100644 --- a/packages/join-flow/src/index.tsx +++ b/packages/join-flow/src/index.tsx @@ -24,7 +24,7 @@ const init = () => { const sentryDsn = getEnvStr("SENTRY_DSN") Sentry.init({ dsn: sentryDsn, - release: "1.4.37" + release: "1.4.39" }); if (getEnv('USE_CHARGEBEE')) {