From f9d682c469db670b287cf7d9936123c8ef09af8a Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Mon, 25 May 2026 16:00:30 +0200 Subject: [PATCH] feat: add /audience SDK routes --- .gitignore | 1 + CHANGELOG.md | 22 ++ README.md | 4 +- gradle.properties | 2 +- src/main/java/com/lettr/Lettr.java | 4 + .../java/com/lettr/core/net/HttpClient.java | 88 +++++++- .../com/lettr/services/audience/Audience.java | 52 +++++ .../audience/contacts/AudienceContacts.java | 150 ++++++++++++++ .../contacts/model/AudienceContactStatus.java | 14 ++ .../contacts/model/AudienceContactView.java | 92 +++++++++ .../model/BulkAttachContactsResponse.java | 25 +++ .../model/BulkContactListsOptions.java | 45 ++++ .../BulkCreateAudienceContactsOptions.java | 78 +++++++ .../BulkCreateAudienceContactsResponse.java | 24 +++ .../model/BulkDetachContactsResponse.java | 25 +++ .../model/CreateAudienceContactOptions.java | 89 ++++++++ .../contacts/model/DoubleOptInConfig.java | 106 ++++++++++ .../model/ListAudienceContactsParams.java | 104 ++++++++++ .../model/ListAudienceContactsResponse.java | 27 +++ .../model/NullablePropertiesAdapter.java | 64 ++++++ .../model/UpdateAudienceContactOptions.java | 87 ++++++++ .../audience/lists/AudienceLists.java | 85 ++++++++ .../lists/model/AudienceListView.java | 36 ++++ .../model/BulkDeleteAudienceListsOptions.java | 36 ++++ .../BulkDeleteAudienceListsResponse.java | 18 ++ .../model/CreateAudienceListOptions.java | 28 +++ .../model/ListAudienceListsResponse.java | 27 +++ .../model/UpdateAudienceListOptions.java | 44 ++++ .../audience/model/AudiencePagination.java | 44 ++++ .../services/audience/model/PageParams.java | 60 ++++++ .../properties/AudienceProperties.java | 68 ++++++ .../model/AudiencePropertyType.java | 11 + .../model/AudiencePropertyView.java | 30 +++ .../model/CreateAudiencePropertyOptions.java | 74 +++++++ .../model/ListAudiencePropertiesResponse.java | 20 ++ .../model/UpdateAudiencePropertyOptions.java | 37 ++++ .../UpdateAudiencePropertyOptionsAdapter.java | 40 ++++ .../audience/segments/AudienceSegments.java | 68 ++++++ .../segments/model/AudienceSegmentView.java | 41 ++++ .../model/CreateAudienceSegmentOptions.java | 71 +++++++ .../model/ListAudienceSegmentsParams.java | 65 ++++++ .../model/ListAudienceSegmentsResponse.java | 20 ++ .../segments/model/SegmentCondition.java | 43 ++++ .../segments/model/SegmentConditionGroup.java | 36 ++++ .../model/SegmentConditionsInput.java | 37 ++++ .../segments/model/SegmentOperator.java | 25 +++ .../model/UpdateAudienceSegmentOptions.java | 62 ++++++ .../audience/topics/AudienceTopics.java | 68 ++++++ .../AudienceTopicDefaultSubscription.java | 8 + .../topics/model/AudienceTopicView.java | 37 ++++ .../topics/model/AudienceTopicVisibility.java | 8 + .../model/CreateAudienceTopicOptions.java | 83 ++++++++ .../model/ListAudienceTopicsResponse.java | 20 ++ .../model/UpdateAudienceTopicOptions.java | 62 ++++++ .../contacts/AudienceContactsTest.java | 194 ++++++++++++++++++ .../audience/lists/AudienceListsTest.java | 107 ++++++++++ .../properties/AudiencePropertiesTest.java | 104 ++++++++++ .../segments/AudienceSegmentsTest.java | 138 +++++++++++++ .../audience/topics/AudienceTopicsTest.java | 94 +++++++++ 59 files changed, 3147 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/lettr/services/audience/Audience.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/AudienceContactStatus.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/AudienceContactView.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/BulkAttachContactsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/BulkContactListsOptions.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/BulkDetachContactsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/CreateAudienceContactOptions.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/DoubleOptInConfig.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsParams.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/NullablePropertiesAdapter.java create mode 100644 src/main/java/com/lettr/services/audience/contacts/model/UpdateAudienceContactOptions.java create mode 100644 src/main/java/com/lettr/services/audience/lists/AudienceLists.java create mode 100644 src/main/java/com/lettr/services/audience/lists/model/AudienceListView.java create mode 100644 src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsOptions.java create mode 100644 src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/lists/model/CreateAudienceListOptions.java create mode 100644 src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/lists/model/UpdateAudienceListOptions.java create mode 100644 src/main/java/com/lettr/services/audience/model/AudiencePagination.java create mode 100644 src/main/java/com/lettr/services/audience/model/PageParams.java create mode 100644 src/main/java/com/lettr/services/audience/properties/AudienceProperties.java create mode 100644 src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyType.java create mode 100644 src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyView.java create mode 100644 src/main/java/com/lettr/services/audience/properties/model/CreateAudiencePropertyOptions.java create mode 100644 src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java create mode 100644 src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptions.java create mode 100644 src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptionsAdapter.java create mode 100644 src/main/java/com/lettr/services/audience/segments/AudienceSegments.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/AudienceSegmentView.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/CreateAudienceSegmentOptions.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsParams.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/SegmentCondition.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/SegmentConditionGroup.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/SegmentConditionsInput.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/SegmentOperator.java create mode 100644 src/main/java/com/lettr/services/audience/segments/model/UpdateAudienceSegmentOptions.java create mode 100644 src/main/java/com/lettr/services/audience/topics/AudienceTopics.java create mode 100644 src/main/java/com/lettr/services/audience/topics/model/AudienceTopicDefaultSubscription.java create mode 100644 src/main/java/com/lettr/services/audience/topics/model/AudienceTopicView.java create mode 100644 src/main/java/com/lettr/services/audience/topics/model/AudienceTopicVisibility.java create mode 100644 src/main/java/com/lettr/services/audience/topics/model/CreateAudienceTopicOptions.java create mode 100644 src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java create mode 100644 src/main/java/com/lettr/services/audience/topics/model/UpdateAudienceTopicOptions.java create mode 100644 src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java create mode 100644 src/test/java/com/lettr/services/audience/lists/AudienceListsTest.java create mode 100644 src/test/java/com/lettr/services/audience/properties/AudiencePropertiesTest.java create mode 100644 src/test/java/com/lettr/services/audience/segments/AudienceSegmentsTest.java create mode 100644 src/test/java/com/lettr/services/audience/topics/AudienceTopicsTest.java diff --git a/.gitignore b/.gitignore index 75f2171..c23e3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ build/ .project .classpath .cursor/ +bin/ # OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7a135..3fcfa96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-05-25 + +### Added + +- **Audience** namespace covering 33 endpoints across five sub-services, reached via `lettr.audience()`: + - `lists()` — list, get, create, update, delete, bulk delete + - `contacts()` — list, get, create (with optional double opt-in), bulk create, update, delete, attach/detach to lists (single + bulk), subscribe/unsubscribe to topics + - `topics()` — list, get, create, update, delete + - `properties()` — list, get, create, update, delete + - `segments()` — list, get, create, update, delete (with full `SegmentOperator` / `SegmentCondition` / `SegmentConditionGroup` modelling) +- `HttpClient.patch(path, body, type)` — used by every audience update endpoint +- `HttpClient.delete(path, body, type)` — used by `/audience/lists/bulk` and `/audience/contacts/lists/bulk` +- `HttpClient.post(path, body)` — void overload for attach/subscribe endpoints that return only `{message}` +- `NullablePropertiesAdapter` — preserves `null` map values when serializing `UpdateAudienceContactOptions.properties`, so the spec's "set a property to null to delete it" semantics actually work +- `UpdateAudiencePropertyOptionsAdapter` — always emits `fallback_value`, so `UpdateAudiencePropertyOptions.withFallbackValue(null)` clears the fallback instead of being silently dropped +- 46 unit tests covering the new audience namespace (Gson deserialization + argument validation) + +### Notes + +- `UpdateAudienceContactOptions.status(...)` now rejects `BOUNCED`, `COMPLAINED`, and `UNVERIFIED` at builder time — the API only accepts `SUBSCRIBED` / `UNSUBSCRIBED` for updates; the other statuses are server-managed +- `/audience/confirm/{token}` is intentionally not covered (token-flow endpoint) + ## [1.1.0] - 2026-04-22 ### Added diff --git a/README.md b/README.md index 7995286..45d90ae 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The official Java SDK for the [Lettr](https://lettr.com) Email API. Send transac ### Gradle ```groovy -implementation 'com.lettr:lettr-java:1.0.0' +implementation 'com.lettr:lettr-java:1.2.0' ``` ### Maven @@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:1.0.0' com.lettr lettr-java - 1.0.0 + 1.2.0 ``` diff --git a/gradle.properties b/gradle.properties index 186a9de..4ec527c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=com.lettr -VERSION=1.1.0 +VERSION=1.2.0 POM_ARTIFACT_ID=lettr-java POM_NAME=Lettr Java SDK POM_DESCRIPTION=Java SDK for the Lettr Email API diff --git a/src/main/java/com/lettr/Lettr.java b/src/main/java/com/lettr/Lettr.java index 5121abb..225ba81 100644 --- a/src/main/java/com/lettr/Lettr.java +++ b/src/main/java/com/lettr/Lettr.java @@ -1,5 +1,6 @@ package com.lettr; +import com.lettr.services.audience.Audience; import com.lettr.services.domains.Domains; import com.lettr.services.emails.Emails; import com.lettr.services.projects.Projects; @@ -63,4 +64,7 @@ public Lettr(@Nonnull String apiKey) { /** Returns the System service for health checks and API key validation. */ @Nonnull public System system() { return new System(apiKey); } + + /** Returns the Audience namespace for managing lists, contacts, topics, properties, and segments. */ + @Nonnull public Audience audience() { return new Audience(apiKey); } } diff --git a/src/main/java/com/lettr/core/net/HttpClient.java b/src/main/java/com/lettr/core/net/HttpClient.java index 7a69384..4350937 100644 --- a/src/main/java/com/lettr/core/net/HttpClient.java +++ b/src/main/java/com/lettr/core/net/HttpClient.java @@ -27,7 +27,7 @@ public class HttpClient { private static final String BASE_URL = "https://app.lettr.com/api"; - private static final String USER_AGENT = "lettr-java/1.0.0"; + private static final String USER_AGENT = "lettr-java/1.2.0"; private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); private final String apiKey; @@ -123,6 +123,58 @@ public T put(String path, Object body, Type responseType) throws LettrExcept return execute(request, responseType); } + /** + * Perform a POST request without expecting a deserialized response body. + * Useful for endpoints that return only a {@code {"message": "..."}} envelope. + * + * @param path API path + * @param body request body object (will be serialized to JSON, or {@code null} for no body) + * @throws LettrException on error + */ + public void post(String path, Object body) throws LettrException { + String url = buildUrl(path, null); + String jsonBody = gson.toJson(body); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(DEFAULT_TIMEOUT) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + executeNoResponse(request); + } + + /** + * Perform a PATCH request with a JSON body. + * + * @param path API path + * @param body request body object (will be serialized to JSON) + * @param responseType the type to deserialize the "data" field into + * @param response data type + * @return deserialized response data + * @throws LettrException on error + */ + public T patch(String path, Object body, Type responseType) throws LettrException { + String url = buildUrl(path, null); + String jsonBody = gson.toJson(body); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(DEFAULT_TIMEOUT) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .method("PATCH", HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + return execute(request, responseType); + } + /** * Perform a DELETE request. * @@ -130,7 +182,7 @@ public T put(String path, Object body, Type responseType) throws LettrExcept * @throws LettrException on error */ public void delete(String path) throws LettrException { - delete(path, null); + delete(path, (Map) null); } /** @@ -152,6 +204,38 @@ public void delete(String path, Map queryParams) throws LettrExc .DELETE() .build(); + executeNoResponse(request); + } + + /** + * Perform a DELETE request with a JSON body, returning a deserialized response. + * Used by bulk delete endpoints that report counts. + * + * @param path API path + * @param body request body object (will be serialized to JSON) + * @param responseType the type to deserialize the "data" field into + * @param response data type + * @return deserialized response data + * @throws LettrException on error + */ + public T delete(String path, Object body, Type responseType) throws LettrException { + String url = buildUrl(path, null); + String jsonBody = gson.toJson(body); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(DEFAULT_TIMEOUT) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .method("DELETE", HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + return execute(request, responseType); + } + + private void executeNoResponse(HttpRequest request) throws LettrException { try { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); int statusCode = response.statusCode(); diff --git a/src/main/java/com/lettr/services/audience/Audience.java b/src/main/java/com/lettr/services/audience/Audience.java new file mode 100644 index 0000000..256b92d --- /dev/null +++ b/src/main/java/com/lettr/services/audience/Audience.java @@ -0,0 +1,52 @@ +package com.lettr.services.audience; + +import com.lettr.services.audience.contacts.AudienceContacts; +import com.lettr.services.audience.lists.AudienceLists; +import com.lettr.services.audience.properties.AudienceProperties; +import com.lettr.services.audience.segments.AudienceSegments; +import com.lettr.services.audience.topics.AudienceTopics; + +import javax.annotation.Nonnull; + +/** + * Entry point for the Lettr audience API. Returns sub-services for managing + * audience lists, contacts, topics, properties, and segments. + */ +public class Audience { + + private final String apiKey; + + public Audience(@Nonnull String apiKey) { + this.apiKey = apiKey; + } + + /** Returns the audience lists service. */ + @Nonnull + public AudienceLists lists() { + return new AudienceLists(apiKey); + } + + /** Returns the audience contacts service. */ + @Nonnull + public AudienceContacts contacts() { + return new AudienceContacts(apiKey); + } + + /** Returns the audience topics service. */ + @Nonnull + public AudienceTopics topics() { + return new AudienceTopics(apiKey); + } + + /** Returns the audience properties service. */ + @Nonnull + public AudienceProperties properties() { + return new AudienceProperties(apiKey); + } + + /** Returns the audience segments service. */ + @Nonnull + public AudienceSegments segments() { + return new AudienceSegments(apiKey); + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java b/src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java new file mode 100644 index 0000000..d000eea --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/AudienceContacts.java @@ -0,0 +1,150 @@ +package com.lettr.services.audience.contacts; + +import com.lettr.core.exception.LettrException; +import com.lettr.services.BaseService; +import com.lettr.services.audience.contacts.model.AudienceContactView; +import com.lettr.services.audience.contacts.model.BulkAttachContactsResponse; +import com.lettr.services.audience.contacts.model.BulkContactListsOptions; +import com.lettr.services.audience.contacts.model.BulkCreateAudienceContactsOptions; +import com.lettr.services.audience.contacts.model.BulkCreateAudienceContactsResponse; +import com.lettr.services.audience.contacts.model.BulkDetachContactsResponse; +import com.lettr.services.audience.contacts.model.CreateAudienceContactOptions; +import com.lettr.services.audience.contacts.model.ListAudienceContactsParams; +import com.lettr.services.audience.contacts.model.ListAudienceContactsResponse; +import com.lettr.services.audience.contacts.model.UpdateAudienceContactOptions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Service for managing audience contacts and their list/topic memberships. + */ +public class AudienceContacts extends BaseService { + + public AudienceContacts(@Nonnull String apiKey) { + super(apiKey); + } + + /** List contacts with default pagination. */ + @Nonnull + public ListAudienceContactsResponse list() throws LettrException { + return list(null); + } + + /** List contacts with optional pagination and filters. */ + @Nonnull + public ListAudienceContactsResponse list(@Nullable ListAudienceContactsParams params) throws LettrException { + return httpClient.get("/audience/contacts", + params != null ? params.toQueryParams() : null, + ListAudienceContactsResponse.class); + } + + /** Retrieve a single contact. */ + @Nonnull + public AudienceContactView get(@Nonnull String contactId) throws LettrException { + if (contactId == null || contactId.isEmpty()) { + throw new IllegalArgumentException("contactId is required"); + } + return httpClient.get("/audience/contacts/" + contactId, null, AudienceContactView.class); + } + + /** Create a single contact (optionally with double opt-in). */ + @Nonnull + public AudienceContactView create(@Nonnull CreateAudienceContactOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/contacts", options, AudienceContactView.class); + } + + /** Bulk create up to 1000 contacts. */ + @Nonnull + public BulkCreateAudienceContactsResponse bulkCreate(@Nonnull BulkCreateAudienceContactsOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/contacts/bulk", options, BulkCreateAudienceContactsResponse.class); + } + + /** Partially update a contact. */ + @Nonnull + public AudienceContactView update(@Nonnull String contactId, @Nonnull UpdateAudienceContactOptions options) throws LettrException { + if (contactId == null || contactId.isEmpty()) { + throw new IllegalArgumentException("contactId is required"); + } + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.patch("/audience/contacts/" + contactId, options, AudienceContactView.class); + } + + /** Permanently delete a contact. */ + public void delete(@Nonnull String contactId) throws LettrException { + if (contactId == null || contactId.isEmpty()) { + throw new IllegalArgumentException("contactId is required"); + } + httpClient.delete("/audience/contacts/" + contactId); + } + + /** Bulk attach contacts to lists (cartesian product of contactIds × listIds). */ + @Nonnull + public BulkAttachContactsResponse bulkAttachToLists(@Nonnull BulkContactListsOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/contacts/lists/bulk", options, BulkAttachContactsResponse.class); + } + + /** Bulk detach contacts from lists (cartesian product of contactIds × listIds). */ + @Nonnull + public BulkDetachContactsResponse bulkDetachFromLists(@Nonnull BulkContactListsOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.delete("/audience/contacts/lists/bulk", options, BulkDetachContactsResponse.class); + } + + /** Attach a single contact to a single list. Idempotent. */ + public void attachToList(@Nonnull String contactId, @Nonnull String listId) throws LettrException { + if (contactId == null || contactId.isEmpty()) { + throw new IllegalArgumentException("contactId is required"); + } + if (listId == null || listId.isEmpty()) { + throw new IllegalArgumentException("listId is required"); + } + httpClient.post("/audience/contacts/" + contactId + "/lists/" + listId, null); + } + + /** Detach a single contact from a single list. Idempotent. */ + public void detachFromList(@Nonnull String contactId, @Nonnull String listId) throws LettrException { + if (contactId == null || contactId.isEmpty()) { + throw new IllegalArgumentException("contactId is required"); + } + if (listId == null || listId.isEmpty()) { + throw new IllegalArgumentException("listId is required"); + } + httpClient.delete("/audience/contacts/" + contactId + "/lists/" + listId); + } + + /** Subscribe a contact to a topic. Idempotent. */ + public void subscribeToTopic(@Nonnull String contactId, @Nonnull String topicId) throws LettrException { + if (contactId == null || contactId.isEmpty()) { + throw new IllegalArgumentException("contactId is required"); + } + if (topicId == null || topicId.isEmpty()) { + throw new IllegalArgumentException("topicId is required"); + } + httpClient.post("/audience/contacts/" + contactId + "/topics/" + topicId, null); + } + + /** Unsubscribe a contact from a topic. Idempotent. */ + public void unsubscribeFromTopic(@Nonnull String contactId, @Nonnull String topicId) throws LettrException { + if (contactId == null || contactId.isEmpty()) { + throw new IllegalArgumentException("contactId is required"); + } + if (topicId == null || topicId.isEmpty()) { + throw new IllegalArgumentException("topicId is required"); + } + httpClient.delete("/audience/contacts/" + contactId + "/topics/" + topicId); + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/AudienceContactStatus.java b/src/main/java/com/lettr/services/audience/contacts/model/AudienceContactStatus.java new file mode 100644 index 0000000..3ae9a48 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/AudienceContactStatus.java @@ -0,0 +1,14 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Subscription/verification status of an audience contact. + */ +public enum AudienceContactStatus { + @SerializedName("subscribed") SUBSCRIBED, + @SerializedName("unsubscribed") UNSUBSCRIBED, + @SerializedName("bounced") BOUNCED, + @SerializedName("complained") COMPLAINED, + @SerializedName("unverified") UNVERIFIED +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/AudienceContactView.java b/src/main/java/com/lettr/services/audience/contacts/model/AudienceContactView.java new file mode 100644 index 0000000..26ec01f --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/AudienceContactView.java @@ -0,0 +1,92 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Map; + +/** + * Represents an audience contact, including their list/topic memberships. + */ +public class AudienceContactView { + + private String id; + private String email; + private AudienceContactStatus status; + private Map properties; + + @SerializedName("created_at") + private String createdAt; + + private List lists; + private List topics; + + @Nonnull + public String getId() { + return id; + } + + @Nonnull + public String getEmail() { + return email; + } + + @Nonnull + public AudienceContactStatus getStatus() { + return status; + } + + @Nonnull + public Map getProperties() { + return properties; + } + + @Nonnull + public String getCreatedAt() { + return createdAt; + } + + @Nonnull + public List getLists() { + return lists; + } + + @Nonnull + public List getTopics() { + return topics; + } + + @Override + public String toString() { + return "AudienceContactView{id='" + id + "', email='" + email + "', status=" + status + '}'; + } + + /** Compact reference to a list the contact belongs to. */ + public static class ListLink { + private String id; + private String name; + + @Nonnull public String getId() { return id; } + @Nonnull public String getName() { return name; } + + @Override + public String toString() { + return "ListLink{id='" + id + "', name='" + name + "'}"; + } + } + + /** Compact reference to a topic the contact is subscribed to. */ + public static class TopicLink { + private String id; + private String name; + + @Nonnull public String getId() { return id; } + @Nonnull public String getName() { return name; } + + @Override + public String toString() { + return "TopicLink{id='" + id + "', name='" + name + "'}"; + } + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkAttachContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkAttachContactsResponse.java new file mode 100644 index 0000000..8fafccd --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkAttachContactsResponse.java @@ -0,0 +1,25 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +public class BulkAttachContactsResponse { + + private int attached; + + @SerializedName("already_attached") + private int alreadyAttached; + + @SerializedName("total_pairs") + private int totalPairs; + + public int getAttached() { return attached; } + public int getAlreadyAttached() { return alreadyAttached; } + public int getTotalPairs() { return totalPairs; } + + @Override + public String toString() { + return "BulkAttachContactsResponse{attached=" + attached + + ", alreadyAttached=" + alreadyAttached + + ", totalPairs=" + totalPairs + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkContactListsOptions.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkContactListsOptions.java new file mode 100644 index 0000000..f99a28b --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkContactListsOptions.java @@ -0,0 +1,45 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * Shared request body for bulk attaching or detaching contacts and lists. + * The endpoint applies the cartesian product of {@code contactIds} × {@code listIds}. + */ +public class BulkContactListsOptions { + + @SerializedName("contact_ids") + private final List contactIds; + + @SerializedName("list_ids") + private final List listIds; + + private BulkContactListsOptions(List contactIds, List listIds) { + this.contactIds = contactIds; + this.listIds = listIds; + } + + @Nonnull + public static BulkContactListsOptions of(@Nonnull List contactIds, @Nonnull List listIds) { + if (contactIds == null || contactIds.isEmpty()) { + throw new IllegalArgumentException("contactIds must contain at least one id"); + } + if (contactIds.size() > 1000) { + throw new IllegalArgumentException("contactIds cannot contain more than 1000 ids"); + } + if (listIds == null || listIds.isEmpty()) { + throw new IllegalArgumentException("listIds must contain at least one id"); + } + if (listIds.size() > 50) { + throw new IllegalArgumentException("listIds cannot contain more than 50 ids"); + } + return new BulkContactListsOptions(new ArrayList<>(contactIds), new ArrayList<>(listIds)); + } + + @Nonnull public List getContactIds() { return contactIds; } + @Nonnull public List getListIds() { return listIds; } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java new file mode 100644 index 0000000..8fc6df7 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsOptions.java @@ -0,0 +1,78 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Request body for bulk-creating up to 1000 audience contacts. + */ +public class BulkCreateAudienceContactsOptions { + + private final List emails; + + @SerializedName("list_id") + private final String listId; + + private final Map properties; + + private BulkCreateAudienceContactsOptions(Builder builder) { + this.emails = builder.emails; + this.listId = builder.listId; + this.properties = builder.properties; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull public List getEmails() { return emails; } + @Nullable public String getListId() { return listId; } + @Nullable public Map getProperties() { return properties; } + + public static class Builder { + private List emails; + private String listId; + private Map properties; + + private Builder() {} + + /** (required) 1–1000 email addresses. */ + @Nonnull + public Builder emails(@Nonnull List emails) { + this.emails = emails == null ? null : new ArrayList<>(emails); + return this; + } + + /** (optional) Add all created contacts to this list. */ + @Nonnull + public Builder listId(@Nullable String listId) { + this.listId = listId; + return this; + } + + /** (optional) Custom property values applied to every created contact. */ + @Nonnull + public Builder properties(@Nullable Map properties) { + this.properties = properties == null ? null : new LinkedHashMap<>(properties); + return this; + } + + @Nonnull + public BulkCreateAudienceContactsOptions build() { + if (emails == null || emails.isEmpty()) { + throw new IllegalArgumentException("emails must contain at least one address"); + } + if (emails.size() > 1000) { + throw new IllegalArgumentException("emails cannot contain more than 1000 addresses"); + } + return new BulkCreateAudienceContactsOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java new file mode 100644 index 0000000..317f260 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkCreateAudienceContactsResponse.java @@ -0,0 +1,24 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +public class BulkCreateAudienceContactsResponse { + + private int created; + + @SerializedName("already_existed") + private int alreadyExisted; + + public int getCreated() { + return created; + } + + public int getAlreadyExisted() { + return alreadyExisted; + } + + @Override + public String toString() { + return "BulkCreateAudienceContactsResponse{created=" + created + ", alreadyExisted=" + alreadyExisted + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/BulkDetachContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/BulkDetachContactsResponse.java new file mode 100644 index 0000000..8b0a271 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/BulkDetachContactsResponse.java @@ -0,0 +1,25 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +public class BulkDetachContactsResponse { + + private int detached; + + @SerializedName("not_present") + private int notPresent; + + @SerializedName("total_pairs") + private int totalPairs; + + public int getDetached() { return detached; } + public int getNotPresent() { return notPresent; } + public int getTotalPairs() { return totalPairs; } + + @Override + public String toString() { + return "BulkDetachContactsResponse{detached=" + detached + + ", notPresent=" + notPresent + + ", totalPairs=" + totalPairs + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/CreateAudienceContactOptions.java b/src/main/java/com/lettr/services/audience/contacts/model/CreateAudienceContactOptions.java new file mode 100644 index 0000000..5addeff --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/CreateAudienceContactOptions.java @@ -0,0 +1,89 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Request body for creating an audience contact. + */ +public class CreateAudienceContactOptions { + + private final String email; + + @SerializedName("list_id") + private final String listId; + + private final Map properties; + + @SerializedName("double_opt_in") + private final DoubleOptInConfig doubleOptIn; + + private CreateAudienceContactOptions(Builder builder) { + this.email = builder.email; + this.listId = builder.listId; + this.properties = builder.properties; + this.doubleOptIn = builder.doubleOptIn; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull public String getEmail() { return email; } + @Nullable public String getListId() { return listId; } + @Nullable public Map getProperties() { return properties; } + @Nullable public DoubleOptInConfig getDoubleOptIn() { return doubleOptIn; } + + public static class Builder { + private String email; + private String listId; + private Map properties; + private DoubleOptInConfig doubleOptIn; + + private Builder() {} + + /** (required) The contact's email address. */ + @Nonnull + public Builder email(@Nonnull String email) { + this.email = email; + return this; + } + + /** (optional) Add the contact to this list on creation. */ + @Nonnull + public Builder listId(@Nullable String listId) { + this.listId = listId; + return this; + } + + /** (optional) Custom property values for this contact. */ + @Nonnull + public Builder properties(@Nullable Map properties) { + this.properties = properties == null ? null : new LinkedHashMap<>(properties); + return this; + } + + /** + * (optional) Send a double opt-in confirmation email; contact is + * created in {@code unverified} status until they confirm. + */ + @Nonnull + public Builder doubleOptIn(@Nullable DoubleOptInConfig doubleOptIn) { + this.doubleOptIn = doubleOptIn; + return this; + } + + @Nonnull + public CreateAudienceContactOptions build() { + if (email == null || email.isEmpty()) { + throw new IllegalArgumentException("email is required"); + } + return new CreateAudienceContactOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/DoubleOptInConfig.java b/src/main/java/com/lettr/services/audience/contacts/model/DoubleOptInConfig.java new file mode 100644 index 0000000..3890762 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/DoubleOptInConfig.java @@ -0,0 +1,106 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Configuration for sending a double opt-in confirmation email when creating a contact. + */ +public class DoubleOptInConfig { + + private final String from; + + @SerializedName("from_name") + private final String fromName; + + private final String subject; + + @SerializedName("template_slug") + private final String templateSlug; + + @SerializedName("redirect_url") + private final String redirectUrl; + + private DoubleOptInConfig(Builder builder) { + this.from = builder.from; + this.fromName = builder.fromName; + this.subject = builder.subject; + this.templateSlug = builder.templateSlug; + this.redirectUrl = builder.redirectUrl; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull public String getFrom() { return from; } + @Nullable public String getFromName() { return fromName; } + @Nonnull public String getSubject() { return subject; } + @Nonnull public String getTemplateSlug() { return templateSlug; } + @Nonnull public String getRedirectUrl() { return redirectUrl; } + + public static class Builder { + private String from; + private String fromName; + private String subject; + private String templateSlug; + private String redirectUrl; + + private Builder() {} + + /** (required) The sender email address. */ + @Nonnull + public Builder from(@Nonnull String from) { + this.from = from; + return this; + } + + /** (optional) The sender's display name. */ + @Nonnull + public Builder fromName(@Nullable String fromName) { + this.fromName = fromName; + return this; + } + + /** (required) The subject line for the confirmation email. */ + @Nonnull + public Builder subject(@Nonnull String subject) { + this.subject = subject; + return this; + } + + /** (required) The slug of the email template to use. */ + @Nonnull + public Builder templateSlug(@Nonnull String templateSlug) { + this.templateSlug = templateSlug; + return this; + } + + /** (required) Redirect URL after the contact confirms. */ + @Nonnull + public Builder redirectUrl(@Nonnull String redirectUrl) { + this.redirectUrl = redirectUrl; + return this; + } + + @Nonnull + public DoubleOptInConfig build() { + if (from == null || from.isEmpty()) { + throw new IllegalArgumentException("from is required"); + } + if (subject == null || subject.isEmpty()) { + throw new IllegalArgumentException("subject is required"); + } + if (templateSlug == null || templateSlug.isEmpty()) { + throw new IllegalArgumentException("templateSlug is required"); + } + if (redirectUrl == null || redirectUrl.isEmpty()) { + throw new IllegalArgumentException("redirectUrl is required"); + } + return new DoubleOptInConfig(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsParams.java b/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsParams.java new file mode 100644 index 0000000..349ea7f --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsParams.java @@ -0,0 +1,104 @@ +package com.lettr.services.audience.contacts.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Query parameters for listing audience contacts. All fields optional. + */ +public class ListAudienceContactsParams { + + private final Integer page; + private final Integer perPage; + private final String search; + private final AudienceContactStatus status; + private final String listId; + private final String segmentId; + + private ListAudienceContactsParams(Builder builder) { + this.page = builder.page; + this.perPage = builder.perPage; + this.search = builder.search; + this.status = builder.status; + this.listId = builder.listId; + this.segmentId = builder.segmentId; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull + public Map toQueryParams() { + Map params = new LinkedHashMap<>(); + if (page != null) params.put("page", page.toString()); + if (perPage != null) params.put("per_page", perPage.toString()); + if (search != null) params.put("search", search); + if (status != null) params.put("status", status.name().toLowerCase(Locale.ROOT)); + if (listId != null) params.put("list_id", listId); + if (segmentId != null) params.put("segment_id", segmentId); + return params; + } + + public static class Builder { + private Integer page; + private Integer perPage; + private String search; + private AudienceContactStatus status; + private String listId; + private String segmentId; + + private Builder() {} + + /** (optional) Page number (min 1, default 1). */ + @Nonnull + public Builder page(@Nullable Integer page) { + this.page = page; + return this; + } + + /** (optional) Items per page (1–100, default 20). */ + @Nonnull + public Builder perPage(@Nullable Integer perPage) { + this.perPage = perPage; + return this; + } + + /** (optional) Search by email or contact name (max 255). */ + @Nonnull + public Builder search(@Nullable String search) { + this.search = search; + return this; + } + + /** (optional) Filter by contact status. */ + @Nonnull + public Builder status(@Nullable AudienceContactStatus status) { + this.status = status; + return this; + } + + /** (optional) Restrict to contacts in this list. */ + @Nonnull + public Builder listId(@Nullable String listId) { + this.listId = listId; + return this; + } + + /** (optional) Restrict to contacts matching this segment. */ + @Nonnull + public Builder segmentId(@Nullable String segmentId) { + this.segmentId = segmentId; + return this; + } + + @Nonnull + public ListAudienceContactsParams build() { + return new ListAudienceContactsParams(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java new file mode 100644 index 0000000..d0bb5ea --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java @@ -0,0 +1,27 @@ +package com.lettr.services.audience.contacts.model; + +import com.lettr.services.audience.model.AudiencePagination; + +import javax.annotation.Nonnull; +import java.util.List; + +public class ListAudienceContactsResponse { + + private List contacts; + private AudiencePagination pagination; + + @Nonnull + public List getContacts() { + return contacts; + } + + @Nonnull + public AudiencePagination getPagination() { + return pagination; + } + + @Override + public String toString() { + return "ListAudienceContactsResponse{contacts=" + contacts + ", pagination=" + pagination + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/NullablePropertiesAdapter.java b/src/main/java/com/lettr/services/audience/contacts/model/NullablePropertiesAdapter.java new file mode 100644 index 0000000..307cf9d --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/NullablePropertiesAdapter.java @@ -0,0 +1,64 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Serializes a {@code Map} preserving null values as JSON + * {@code null} rather than dropping the entry. + * + *

The audience contact update API treats a property whose value is JSON + * {@code null} as a deletion request. Gson's default behaviour drops null map + * values, which would silently turn intended deletions into no-ops. This + * adapter keeps them.

+ */ +public class NullablePropertiesAdapter extends TypeAdapter> { + + @Override + public void write(JsonWriter out, Map value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + for (Map.Entry entry : value.entrySet()) { + out.name(entry.getKey()); + if (entry.getValue() == null) { + boolean prev = out.getSerializeNulls(); + out.setSerializeNulls(true); + out.nullValue(); + out.setSerializeNulls(prev); + } else { + out.value(entry.getValue()); + } + } + out.endObject(); + } + + @Override + public Map read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + Map result = new LinkedHashMap<>(); + in.beginObject(); + while (in.hasNext()) { + String key = in.nextName(); + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + result.put(key, null); + } else { + result.put(key, in.nextString()); + } + } + in.endObject(); + return result; + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/UpdateAudienceContactOptions.java b/src/main/java/com/lettr/services/audience/contacts/model/UpdateAudienceContactOptions.java new file mode 100644 index 0000000..a2862e7 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/contacts/model/UpdateAudienceContactOptions.java @@ -0,0 +1,87 @@ +package com.lettr.services.audience.contacts.model; + +import com.google.gson.annotations.JsonAdapter; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Request body for partially updating an audience contact. All fields optional. + * + *

For {@code properties}, setting a value to {@code null} removes that property + * from the contact.

+ */ +public class UpdateAudienceContactOptions { + + private final String email; + private final AudienceContactStatus status; + + @JsonAdapter(NullablePropertiesAdapter.class) + private final Map properties; + + private UpdateAudienceContactOptions(Builder builder) { + this.email = builder.email; + this.status = builder.status; + this.properties = builder.properties; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nullable public String getEmail() { return email; } + @Nullable public AudienceContactStatus getStatus() { return status; } + @Nullable public Map getProperties() { return properties; } + + public static class Builder { + private String email; + private AudienceContactStatus status; + private Map properties; + + private Builder() {} + + /** (optional) New email address (must remain unique). */ + @Nonnull + public Builder email(@Nullable String email) { + this.email = email; + return this; + } + + /** + * (optional) New status. The API only accepts {@link AudienceContactStatus#SUBSCRIBED} + * or {@link AudienceContactStatus#UNSUBSCRIBED} when updating a contact; other statuses + * (bounced, complained, unverified) are server-managed. + * + * @throws IllegalArgumentException if {@code status} is not {@code SUBSCRIBED} or {@code UNSUBSCRIBED} + */ + @Nonnull + public Builder status(@Nullable AudienceContactStatus status) { + if (status != null + && status != AudienceContactStatus.SUBSCRIBED + && status != AudienceContactStatus.UNSUBSCRIBED) { + throw new IllegalArgumentException( + "status must be SUBSCRIBED or UNSUBSCRIBED when updating a contact"); + } + this.status = status; + return this; + } + + /** + * (optional) Partial property update. Use {@code null} as a value + * to remove the property. + */ + @Nonnull + public Builder properties(@Nullable Map properties) { + this.properties = properties == null ? null : new LinkedHashMap<>(properties); + return this; + } + + @Nonnull + public UpdateAudienceContactOptions build() { + return new UpdateAudienceContactOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/lists/AudienceLists.java b/src/main/java/com/lettr/services/audience/lists/AudienceLists.java new file mode 100644 index 0000000..200d22a --- /dev/null +++ b/src/main/java/com/lettr/services/audience/lists/AudienceLists.java @@ -0,0 +1,85 @@ +package com.lettr.services.audience.lists; + +import com.lettr.core.exception.LettrException; +import com.lettr.services.BaseService; +import com.lettr.services.audience.lists.model.AudienceListView; +import com.lettr.services.audience.lists.model.BulkDeleteAudienceListsOptions; +import com.lettr.services.audience.lists.model.BulkDeleteAudienceListsResponse; +import com.lettr.services.audience.lists.model.CreateAudienceListOptions; +import com.lettr.services.audience.lists.model.ListAudienceListsResponse; +import com.lettr.services.audience.lists.model.UpdateAudienceListOptions; +import com.lettr.services.audience.model.PageParams; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Service for managing audience lists. + */ +public class AudienceLists extends BaseService { + + public AudienceLists(@Nonnull String apiKey) { + super(apiKey); + } + + /** List audience lists with default pagination. */ + @Nonnull + public ListAudienceListsResponse list() throws LettrException { + return list(null); + } + + /** List audience lists with optional pagination. */ + @Nonnull + public ListAudienceListsResponse list(@Nullable PageParams params) throws LettrException { + return httpClient.get("/audience/lists", + params != null ? params.toQueryParams() : null, + ListAudienceListsResponse.class); + } + + /** Retrieve a single audience list. */ + @Nonnull + public AudienceListView get(@Nonnull String listId) throws LettrException { + if (listId == null || listId.isEmpty()) { + throw new IllegalArgumentException("listId is required"); + } + return httpClient.get("/audience/lists/" + listId, null, AudienceListView.class); + } + + /** Create a new audience list. */ + @Nonnull + public AudienceListView create(@Nonnull CreateAudienceListOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/lists", options, AudienceListView.class); + } + + /** Partially update an audience list. */ + @Nonnull + public AudienceListView update(@Nonnull String listId, @Nonnull UpdateAudienceListOptions options) throws LettrException { + if (listId == null || listId.isEmpty()) { + throw new IllegalArgumentException("listId is required"); + } + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.patch("/audience/lists/" + listId, options, AudienceListView.class); + } + + /** Permanently delete an audience list. */ + public void delete(@Nonnull String listId) throws LettrException { + if (listId == null || listId.isEmpty()) { + throw new IllegalArgumentException("listId is required"); + } + httpClient.delete("/audience/lists/" + listId); + } + + /** Bulk delete up to 50 audience lists in a single request. */ + @Nonnull + public BulkDeleteAudienceListsResponse bulkDelete(@Nonnull BulkDeleteAudienceListsOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.delete("/audience/lists/bulk", options, BulkDeleteAudienceListsResponse.class); + } +} diff --git a/src/main/java/com/lettr/services/audience/lists/model/AudienceListView.java b/src/main/java/com/lettr/services/audience/lists/model/AudienceListView.java new file mode 100644 index 0000000..a753cb0 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/lists/model/AudienceListView.java @@ -0,0 +1,36 @@ +package com.lettr.services.audience.lists.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; + +/** + * Represents a single audience list as returned by the API. + */ +public class AudienceListView { + + private String id; + private String name; + + @SerializedName("contacts_count") + private int contactsCount; + + @Nonnull + public String getId() { + return id; + } + + @Nonnull + public String getName() { + return name; + } + + public int getContactsCount() { + return contactsCount; + } + + @Override + public String toString() { + return "AudienceListView{id='" + id + "', name='" + name + "', contactsCount=" + contactsCount + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsOptions.java b/src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsOptions.java new file mode 100644 index 0000000..7fcf7c8 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsOptions.java @@ -0,0 +1,36 @@ +package com.lettr.services.audience.lists.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * Request body for bulk-deleting audience lists. Accepts 1–50 list IDs. + */ +public class BulkDeleteAudienceListsOptions { + + @SerializedName("list_ids") + private final List listIds; + + private BulkDeleteAudienceListsOptions(List listIds) { + this.listIds = listIds; + } + + @Nonnull + public static BulkDeleteAudienceListsOptions of(@Nonnull List listIds) { + if (listIds == null || listIds.isEmpty()) { + throw new IllegalArgumentException("listIds must contain at least one id"); + } + if (listIds.size() > 50) { + throw new IllegalArgumentException("listIds cannot contain more than 50 ids"); + } + return new BulkDeleteAudienceListsOptions(new ArrayList<>(listIds)); + } + + @Nonnull + public List getListIds() { + return listIds; + } +} diff --git a/src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsResponse.java b/src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsResponse.java new file mode 100644 index 0000000..acb05ff --- /dev/null +++ b/src/main/java/com/lettr/services/audience/lists/model/BulkDeleteAudienceListsResponse.java @@ -0,0 +1,18 @@ +package com.lettr.services.audience.lists.model; + +/** + * Response for bulk delete of audience lists. + */ +public class BulkDeleteAudienceListsResponse { + + private int deleted; + + public int getDeleted() { + return deleted; + } + + @Override + public String toString() { + return "BulkDeleteAudienceListsResponse{deleted=" + deleted + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/lists/model/CreateAudienceListOptions.java b/src/main/java/com/lettr/services/audience/lists/model/CreateAudienceListOptions.java new file mode 100644 index 0000000..43b6681 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/lists/model/CreateAudienceListOptions.java @@ -0,0 +1,28 @@ +package com.lettr.services.audience.lists.model; + +import javax.annotation.Nonnull; + +/** + * Request body for creating an audience list. Name must be unique within the team. + */ +public class CreateAudienceListOptions { + + private final String name; + + private CreateAudienceListOptions(String name) { + this.name = name; + } + + @Nonnull + public static CreateAudienceListOptions of(@Nonnull String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required"); + } + return new CreateAudienceListOptions(name); + } + + @Nonnull + public String getName() { + return name; + } +} diff --git a/src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java b/src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java new file mode 100644 index 0000000..bcb7f19 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java @@ -0,0 +1,27 @@ +package com.lettr.services.audience.lists.model; + +import com.lettr.services.audience.model.AudiencePagination; + +import javax.annotation.Nonnull; +import java.util.List; + +public class ListAudienceListsResponse { + + private List lists; + private AudiencePagination pagination; + + @Nonnull + public List getLists() { + return lists; + } + + @Nonnull + public AudiencePagination getPagination() { + return pagination; + } + + @Override + public String toString() { + return "ListAudienceListsResponse{lists=" + lists + ", pagination=" + pagination + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/lists/model/UpdateAudienceListOptions.java b/src/main/java/com/lettr/services/audience/lists/model/UpdateAudienceListOptions.java new file mode 100644 index 0000000..822864b --- /dev/null +++ b/src/main/java/com/lettr/services/audience/lists/model/UpdateAudienceListOptions.java @@ -0,0 +1,44 @@ +package com.lettr.services.audience.lists.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Request body for partially updating an audience list. All fields optional. + */ +public class UpdateAudienceListOptions { + + private final String name; + + private UpdateAudienceListOptions(Builder builder) { + this.name = builder.name; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nullable + public String getName() { + return name; + } + + public static class Builder { + private String name; + + private Builder() {} + + /** (optional) New list name (max 255). */ + @Nonnull + public Builder name(@Nullable String name) { + this.name = name; + return this; + } + + @Nonnull + public UpdateAudienceListOptions build() { + return new UpdateAudienceListOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/model/AudiencePagination.java b/src/main/java/com/lettr/services/audience/model/AudiencePagination.java new file mode 100644 index 0000000..cdd4384 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/model/AudiencePagination.java @@ -0,0 +1,44 @@ +package com.lettr.services.audience.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Pagination metadata returned alongside paginated audience list responses. + */ +public class AudiencePagination { + + private int total; + + @SerializedName("per_page") + private int perPage; + + @SerializedName("current_page") + private int currentPage; + + @SerializedName("last_page") + private int lastPage; + + public int getTotal() { + return total; + } + + public int getPerPage() { + return perPage; + } + + public int getCurrentPage() { + return currentPage; + } + + public int getLastPage() { + return lastPage; + } + + @Override + public String toString() { + return "AudiencePagination{total=" + total + + ", perPage=" + perPage + + ", currentPage=" + currentPage + + ", lastPage=" + lastPage + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/model/PageParams.java b/src/main/java/com/lettr/services/audience/model/PageParams.java new file mode 100644 index 0000000..3d521d5 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/model/PageParams.java @@ -0,0 +1,60 @@ +package com.lettr.services.audience.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Shared page/per_page parameters used by listing endpoints that only + * support pagination (no other filters). + */ +public class PageParams { + + private final Integer page; + private final Integer perPage; + + private PageParams(Builder builder) { + this.page = builder.page; + this.perPage = builder.perPage; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull + public Map toQueryParams() { + Map params = new LinkedHashMap<>(); + if (page != null) params.put("page", page.toString()); + if (perPage != null) params.put("per_page", perPage.toString()); + return params; + } + + public static class Builder { + private Integer page; + private Integer perPage; + + private Builder() {} + + /** (optional) Page number (min 1, default 1). */ + @Nonnull + public Builder page(@Nullable Integer page) { + this.page = page; + return this; + } + + /** (optional) Items per page (1–100, default 20). */ + @Nonnull + public Builder perPage(@Nullable Integer perPage) { + this.perPage = perPage; + return this; + } + + @Nonnull + public PageParams build() { + return new PageParams(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/properties/AudienceProperties.java b/src/main/java/com/lettr/services/audience/properties/AudienceProperties.java new file mode 100644 index 0000000..f5d6390 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/properties/AudienceProperties.java @@ -0,0 +1,68 @@ +package com.lettr.services.audience.properties; + +import com.lettr.core.exception.LettrException; +import com.lettr.services.BaseService; +import com.lettr.services.audience.model.PageParams; +import com.lettr.services.audience.properties.model.AudiencePropertyView; +import com.lettr.services.audience.properties.model.CreateAudiencePropertyOptions; +import com.lettr.services.audience.properties.model.ListAudiencePropertiesResponse; +import com.lettr.services.audience.properties.model.UpdateAudiencePropertyOptions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Service for managing custom audience contact properties. + */ +public class AudienceProperties extends BaseService { + + public AudienceProperties(@Nonnull String apiKey) { + super(apiKey); + } + + @Nonnull + public ListAudiencePropertiesResponse list() throws LettrException { + return list(null); + } + + @Nonnull + public ListAudiencePropertiesResponse list(@Nullable PageParams params) throws LettrException { + return httpClient.get("/audience/properties", + params != null ? params.toQueryParams() : null, + ListAudiencePropertiesResponse.class); + } + + @Nonnull + public AudiencePropertyView get(@Nonnull String propertyId) throws LettrException { + if (propertyId == null || propertyId.isEmpty()) { + throw new IllegalArgumentException("propertyId is required"); + } + return httpClient.get("/audience/properties/" + propertyId, null, AudiencePropertyView.class); + } + + @Nonnull + public AudiencePropertyView create(@Nonnull CreateAudiencePropertyOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/properties", options, AudiencePropertyView.class); + } + + @Nonnull + public AudiencePropertyView update(@Nonnull String propertyId, @Nonnull UpdateAudiencePropertyOptions options) throws LettrException { + if (propertyId == null || propertyId.isEmpty()) { + throw new IllegalArgumentException("propertyId is required"); + } + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.patch("/audience/properties/" + propertyId, options, AudiencePropertyView.class); + } + + public void delete(@Nonnull String propertyId) throws LettrException { + if (propertyId == null || propertyId.isEmpty()) { + throw new IllegalArgumentException("propertyId is required"); + } + httpClient.delete("/audience/properties/" + propertyId); + } +} diff --git a/src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyType.java b/src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyType.java new file mode 100644 index 0000000..9ce0b87 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyType.java @@ -0,0 +1,11 @@ +package com.lettr.services.audience.properties.model; + +import com.google.gson.annotations.SerializedName; + +public enum AudiencePropertyType { + @SerializedName("string") STRING, + @SerializedName("number") NUMBER, + @SerializedName("boolean") BOOLEAN, + @SerializedName("date") DATE, + @SerializedName("json") JSON +} diff --git a/src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyView.java b/src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyView.java new file mode 100644 index 0000000..0626da7 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/properties/model/AudiencePropertyView.java @@ -0,0 +1,30 @@ +package com.lettr.services.audience.properties.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class AudiencePropertyView { + + private String id; + private String name; + private AudiencePropertyType type; + + @SerializedName("fallback_value") + private String fallbackValue; + + @SerializedName("created_at") + private String createdAt; + + @Nonnull public String getId() { return id; } + @Nonnull public String getName() { return name; } + @Nonnull public AudiencePropertyType getType() { return type; } + @Nullable public String getFallbackValue() { return fallbackValue; } + @Nonnull public String getCreatedAt() { return createdAt; } + + @Override + public String toString() { + return "AudiencePropertyView{id='" + id + "', name='" + name + "', type=" + type + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/properties/model/CreateAudiencePropertyOptions.java b/src/main/java/com/lettr/services/audience/properties/model/CreateAudiencePropertyOptions.java new file mode 100644 index 0000000..6afe252 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/properties/model/CreateAudiencePropertyOptions.java @@ -0,0 +1,74 @@ +package com.lettr.services.audience.properties.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Request body for creating an audience property. {@code name} and {@code type} + * are immutable after creation. + */ +public class CreateAudiencePropertyOptions { + + private final String name; + private final AudiencePropertyType type; + + @SerializedName("fallback_value") + private final String fallbackValue; + + private CreateAudiencePropertyOptions(Builder builder) { + this.name = builder.name; + this.type = builder.type; + this.fallbackValue = builder.fallbackValue; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull public String getName() { return name; } + @Nonnull public AudiencePropertyType getType() { return type; } + @Nullable public String getFallbackValue() { return fallbackValue; } + + public static class Builder { + private String name; + private AudiencePropertyType type; + private String fallbackValue; + + private Builder() {} + + /** (required) Property name. Must match {@code ^[a-z][a-z0-9_]*$}. */ + @Nonnull + public Builder name(@Nonnull String name) { + this.name = name; + return this; + } + + /** (required) Property data type. */ + @Nonnull + public Builder type(@Nonnull AudiencePropertyType type) { + this.type = type; + return this; + } + + /** (optional) Fallback value when the property is not set on a contact. */ + @Nonnull + public Builder fallbackValue(@Nullable String fallbackValue) { + this.fallbackValue = fallbackValue; + return this; + } + + @Nonnull + public CreateAudiencePropertyOptions build() { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required"); + } + if (type == null) { + throw new IllegalArgumentException("type is required"); + } + return new CreateAudiencePropertyOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java b/src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java new file mode 100644 index 0000000..3308f3c --- /dev/null +++ b/src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java @@ -0,0 +1,20 @@ +package com.lettr.services.audience.properties.model; + +import com.lettr.services.audience.model.AudiencePagination; + +import javax.annotation.Nonnull; +import java.util.List; + +public class ListAudiencePropertiesResponse { + + private List properties; + private AudiencePagination pagination; + + @Nonnull public List getProperties() { return properties; } + @Nonnull public AudiencePagination getPagination() { return pagination; } + + @Override + public String toString() { + return "ListAudiencePropertiesResponse{properties=" + properties + ", pagination=" + pagination + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptions.java b/src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptions.java new file mode 100644 index 0000000..494c0bb --- /dev/null +++ b/src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptions.java @@ -0,0 +1,37 @@ +package com.lettr.services.audience.properties.model; + +import com.google.gson.annotations.JsonAdapter; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Request body for updating an audience property. Only {@code fallbackValue} + * can be changed — {@code name} and {@code type} are immutable. + * + *

Passing {@code null} to {@link #withFallbackValue(String)} clears the + * fallback by sending {@code "fallback_value": null} on the wire.

+ */ +@JsonAdapter(UpdateAudiencePropertyOptionsAdapter.class) +public class UpdateAudiencePropertyOptions { + + private final String fallbackValue; + + private UpdateAudiencePropertyOptions(String fallbackValue) { + this.fallbackValue = fallbackValue; + } + + /** + * Build with an explicit fallback value. Passing {@code null} clears the + * existing fallback (the wire request sends {@code "fallback_value": null}). + */ + @Nonnull + public static UpdateAudiencePropertyOptions withFallbackValue(@Nullable String fallbackValue) { + return new UpdateAudiencePropertyOptions(fallbackValue); + } + + @Nullable + public String getFallbackValue() { + return fallbackValue; + } +} diff --git a/src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptionsAdapter.java b/src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptionsAdapter.java new file mode 100644 index 0000000..1b2c115 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/properties/model/UpdateAudiencePropertyOptionsAdapter.java @@ -0,0 +1,40 @@ +package com.lettr.services.audience.properties.model; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +/** + * Always emits the {@code fallback_value} field — including as JSON {@code null} + * when the caller wants to clear it. Without this, Gson would drop the field + * for a null fallback and the API would treat the PATCH as a no-op. + */ +class UpdateAudiencePropertyOptionsAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter out, UpdateAudiencePropertyOptions value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + out.name("fallback_value"); + String fallback = value.getFallbackValue(); + if (fallback == null) { + boolean prev = out.getSerializeNulls(); + out.setSerializeNulls(true); + out.nullValue(); + out.setSerializeNulls(prev); + } else { + out.value(fallback); + } + out.endObject(); + } + + @Override + public UpdateAudiencePropertyOptions read(JsonReader in) throws IOException { + throw new UnsupportedOperationException("UpdateAudiencePropertyOptions is request-only"); + } +} diff --git a/src/main/java/com/lettr/services/audience/segments/AudienceSegments.java b/src/main/java/com/lettr/services/audience/segments/AudienceSegments.java new file mode 100644 index 0000000..d935899 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/AudienceSegments.java @@ -0,0 +1,68 @@ +package com.lettr.services.audience.segments; + +import com.lettr.core.exception.LettrException; +import com.lettr.services.BaseService; +import com.lettr.services.audience.segments.model.AudienceSegmentView; +import com.lettr.services.audience.segments.model.CreateAudienceSegmentOptions; +import com.lettr.services.audience.segments.model.ListAudienceSegmentsParams; +import com.lettr.services.audience.segments.model.ListAudienceSegmentsResponse; +import com.lettr.services.audience.segments.model.UpdateAudienceSegmentOptions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Service for managing audience segments. + */ +public class AudienceSegments extends BaseService { + + public AudienceSegments(@Nonnull String apiKey) { + super(apiKey); + } + + @Nonnull + public ListAudienceSegmentsResponse list() throws LettrException { + return list(null); + } + + @Nonnull + public ListAudienceSegmentsResponse list(@Nullable ListAudienceSegmentsParams params) throws LettrException { + return httpClient.get("/audience/segments", + params != null ? params.toQueryParams() : null, + ListAudienceSegmentsResponse.class); + } + + @Nonnull + public AudienceSegmentView get(@Nonnull String segmentId) throws LettrException { + if (segmentId == null || segmentId.isEmpty()) { + throw new IllegalArgumentException("segmentId is required"); + } + return httpClient.get("/audience/segments/" + segmentId, null, AudienceSegmentView.class); + } + + @Nonnull + public AudienceSegmentView create(@Nonnull CreateAudienceSegmentOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/segments", options, AudienceSegmentView.class); + } + + @Nonnull + public AudienceSegmentView update(@Nonnull String segmentId, @Nonnull UpdateAudienceSegmentOptions options) throws LettrException { + if (segmentId == null || segmentId.isEmpty()) { + throw new IllegalArgumentException("segmentId is required"); + } + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.patch("/audience/segments/" + segmentId, options, AudienceSegmentView.class); + } + + public void delete(@Nonnull String segmentId) throws LettrException { + if (segmentId == null || segmentId.isEmpty()) { + throw new IllegalArgumentException("segmentId is required"); + } + httpClient.delete("/audience/segments/" + segmentId); + } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/AudienceSegmentView.java b/src/main/java/com/lettr/services/audience/segments/model/AudienceSegmentView.java new file mode 100644 index 0000000..aea3dc6 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/AudienceSegmentView.java @@ -0,0 +1,41 @@ +package com.lettr.services.audience.segments.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; + +public class AudienceSegmentView { + + private String id; + private String name; + + @SerializedName("list_id") + private String listId; + + @SerializedName("list_name") + private String listName; + + @SerializedName("condition_groups") + private List conditionGroups; + + @SerializedName("cached_contacts_count") + private Integer cachedContactsCount; + + @SerializedName("created_at") + private String createdAt; + + @Nonnull public String getId() { return id; } + @Nonnull public String getName() { return name; } + @Nullable public String getListId() { return listId; } + @Nullable public String getListName() { return listName; } + @Nonnull public List getConditionGroups() { return conditionGroups; } + @Nullable public Integer getCachedContactsCount() { return cachedContactsCount; } + @Nonnull public String getCreatedAt() { return createdAt; } + + @Override + public String toString() { + return "AudienceSegmentView{id='" + id + "', name='" + name + "', listId='" + listId + "'}"; + } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/CreateAudienceSegmentOptions.java b/src/main/java/com/lettr/services/audience/segments/model/CreateAudienceSegmentOptions.java new file mode 100644 index 0000000..0af7837 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/CreateAudienceSegmentOptions.java @@ -0,0 +1,71 @@ +package com.lettr.services.audience.segments.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class CreateAudienceSegmentOptions { + + private final String name; + + @SerializedName("list_id") + private final String listId; + + private final SegmentConditionsInput conditions; + + private CreateAudienceSegmentOptions(Builder builder) { + this.name = builder.name; + this.listId = builder.listId; + this.conditions = builder.conditions; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull public String getName() { return name; } + @Nullable public String getListId() { return listId; } + @Nonnull public SegmentConditionsInput getConditions() { return conditions; } + + public static class Builder { + private String name; + private String listId; + private SegmentConditionsInput conditions; + + private Builder() {} + + /** (required) Segment name (max 255). */ + @Nonnull + public Builder name(@Nonnull String name) { + this.name = name; + return this; + } + + /** (optional) Restrict the segment to contacts within this list. */ + @Nonnull + public Builder listId(@Nullable String listId) { + this.listId = listId; + return this; + } + + /** (required) Condition groups defining the segment. */ + @Nonnull + public Builder conditions(@Nonnull SegmentConditionsInput conditions) { + this.conditions = conditions; + return this; + } + + @Nonnull + public CreateAudienceSegmentOptions build() { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required"); + } + if (conditions == null) { + throw new IllegalArgumentException("conditions is required"); + } + return new CreateAudienceSegmentOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsParams.java b/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsParams.java new file mode 100644 index 0000000..30ec330 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsParams.java @@ -0,0 +1,65 @@ +package com.lettr.services.audience.segments.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +public class ListAudienceSegmentsParams { + + private final Integer page; + private final Integer perPage; + private final String listId; + + private ListAudienceSegmentsParams(Builder builder) { + this.page = builder.page; + this.perPage = builder.perPage; + this.listId = builder.listId; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull + public Map toQueryParams() { + Map params = new LinkedHashMap<>(); + if (page != null) params.put("page", page.toString()); + if (perPage != null) params.put("per_page", perPage.toString()); + if (listId != null) params.put("list_id", listId); + return params; + } + + public static class Builder { + private Integer page; + private Integer perPage; + private String listId; + + private Builder() {} + + @Nonnull + public Builder page(@Nullable Integer page) { + this.page = page; + return this; + } + + @Nonnull + public Builder perPage(@Nullable Integer perPage) { + this.perPage = perPage; + return this; + } + + /** (optional) Restrict to segments scoped to this list. */ + @Nonnull + public Builder listId(@Nullable String listId) { + this.listId = listId; + return this; + } + + @Nonnull + public ListAudienceSegmentsParams build() { + return new ListAudienceSegmentsParams(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java b/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java new file mode 100644 index 0000000..bec03ce --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java @@ -0,0 +1,20 @@ +package com.lettr.services.audience.segments.model; + +import com.lettr.services.audience.model.AudiencePagination; + +import javax.annotation.Nonnull; +import java.util.List; + +public class ListAudienceSegmentsResponse { + + private List segments; + private AudiencePagination pagination; + + @Nonnull public List getSegments() { return segments; } + @Nonnull public AudiencePagination getPagination() { return pagination; } + + @Override + public String toString() { + return "ListAudienceSegmentsResponse{segments=" + segments + ", pagination=" + pagination + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/SegmentCondition.java b/src/main/java/com/lettr/services/audience/segments/model/SegmentCondition.java new file mode 100644 index 0000000..cd1e76f --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/SegmentCondition.java @@ -0,0 +1,43 @@ +package com.lettr.services.audience.segments.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A single condition inside a segment. {@code value} is required for most + * operators; {@code IS_TRUE} and {@code IS_FALSE} omit it. + */ +public class SegmentCondition { + + private final String field; + private final SegmentOperator operator; + private final String value; + + private SegmentCondition(String field, SegmentOperator operator, String value) { + this.field = field; + this.operator = operator; + this.value = value; + } + + /** Create a condition that uses a value (e.g. equals, contains, before). */ + @Nonnull + public static SegmentCondition of(@Nonnull String field, @Nonnull SegmentOperator operator, @Nullable String value) { + if (field == null || field.isEmpty()) { + throw new IllegalArgumentException("field is required"); + } + if (operator == null) { + throw new IllegalArgumentException("operator is required"); + } + return new SegmentCondition(field, operator, value); + } + + /** Create a value-less condition (only valid for {@code IS_TRUE} / {@code IS_FALSE}). */ + @Nonnull + public static SegmentCondition of(@Nonnull String field, @Nonnull SegmentOperator operator) { + return of(field, operator, null); + } + + @Nonnull public String getField() { return field; } + @Nonnull public SegmentOperator getOperator() { return operator; } + @Nullable public String getValue() { return value; } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/SegmentConditionGroup.java b/src/main/java/com/lettr/services/audience/segments/model/SegmentConditionGroup.java new file mode 100644 index 0000000..f8c3354 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/SegmentConditionGroup.java @@ -0,0 +1,36 @@ +package com.lettr.services.audience.segments.model; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A group of conditions joined by AND. Multiple groups in a segment are joined by OR. + */ +public class SegmentConditionGroup { + + private final List conditions; + + private SegmentConditionGroup(List conditions) { + this.conditions = conditions; + } + + @Nonnull + public static SegmentConditionGroup of(@Nonnull List conditions) { + if (conditions == null || conditions.isEmpty()) { + throw new IllegalArgumentException("conditions must contain at least one item"); + } + return new SegmentConditionGroup(new ArrayList<>(conditions)); + } + + @Nonnull + public static SegmentConditionGroup of(@Nonnull SegmentCondition... conditions) { + if (conditions == null || conditions.length == 0) { + throw new IllegalArgumentException("conditions must contain at least one item"); + } + return new SegmentConditionGroup(new ArrayList<>(Arrays.asList(conditions))); + } + + @Nonnull public List getConditions() { return conditions; } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/SegmentConditionsInput.java b/src/main/java/com/lettr/services/audience/segments/model/SegmentConditionsInput.java new file mode 100644 index 0000000..0428c42 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/SegmentConditionsInput.java @@ -0,0 +1,37 @@ +package com.lettr.services.audience.segments.model; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Wrapper used as the {@code conditions} field on segment create/update requests. + * The top-level groups are joined by OR; conditions within each group are joined by AND. + */ +public class SegmentConditionsInput { + + private final List groups; + + private SegmentConditionsInput(List groups) { + this.groups = groups; + } + + @Nonnull + public static SegmentConditionsInput of(@Nonnull List groups) { + if (groups == null || groups.isEmpty()) { + throw new IllegalArgumentException("groups must contain at least one item"); + } + return new SegmentConditionsInput(new ArrayList<>(groups)); + } + + @Nonnull + public static SegmentConditionsInput of(@Nonnull SegmentConditionGroup... groups) { + if (groups == null || groups.length == 0) { + throw new IllegalArgumentException("groups must contain at least one item"); + } + return new SegmentConditionsInput(new ArrayList<>(Arrays.asList(groups))); + } + + @Nonnull public List getGroups() { return groups; } +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/SegmentOperator.java b/src/main/java/com/lettr/services/audience/segments/model/SegmentOperator.java new file mode 100644 index 0000000..a116bfe --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/SegmentOperator.java @@ -0,0 +1,25 @@ +package com.lettr.services.audience.segments.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Comparison operator for a segment condition. + */ +public enum SegmentOperator { + @SerializedName("contains") CONTAINS, + @SerializedName("not_contains") NOT_CONTAINS, + @SerializedName("equals") EQUALS, + @SerializedName("not_equals") NOT_EQUALS, + @SerializedName("starts_with") STARTS_WITH, + @SerializedName("not_starts_with") NOT_STARTS_WITH, + @SerializedName("ends_with") ENDS_WITH, + @SerializedName("not_ends_with") NOT_ENDS_WITH, + @SerializedName("is_true") IS_TRUE, + @SerializedName("is_false") IS_FALSE, + @SerializedName("greater_than") GREATER_THAN, + @SerializedName("greater_than_or_equal") GREATER_THAN_OR_EQUAL, + @SerializedName("less_than") LESS_THAN, + @SerializedName("less_than_or_equal") LESS_THAN_OR_EQUAL, + @SerializedName("before") BEFORE, + @SerializedName("after") AFTER +} diff --git a/src/main/java/com/lettr/services/audience/segments/model/UpdateAudienceSegmentOptions.java b/src/main/java/com/lettr/services/audience/segments/model/UpdateAudienceSegmentOptions.java new file mode 100644 index 0000000..c77a5e5 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/segments/model/UpdateAudienceSegmentOptions.java @@ -0,0 +1,62 @@ +package com.lettr.services.audience.segments.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class UpdateAudienceSegmentOptions { + + private final String name; + + @SerializedName("list_id") + private final String listId; + + private final SegmentConditionsInput conditions; + + private UpdateAudienceSegmentOptions(Builder builder) { + this.name = builder.name; + this.listId = builder.listId; + this.conditions = builder.conditions; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nullable public String getName() { return name; } + @Nullable public String getListId() { return listId; } + @Nullable public SegmentConditionsInput getConditions() { return conditions; } + + public static class Builder { + private String name; + private String listId; + private SegmentConditionsInput conditions; + + private Builder() {} + + @Nonnull + public Builder name(@Nullable String name) { + this.name = name; + return this; + } + + @Nonnull + public Builder listId(@Nullable String listId) { + this.listId = listId; + return this; + } + + @Nonnull + public Builder conditions(@Nullable SegmentConditionsInput conditions) { + this.conditions = conditions; + return this; + } + + @Nonnull + public UpdateAudienceSegmentOptions build() { + return new UpdateAudienceSegmentOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/topics/AudienceTopics.java b/src/main/java/com/lettr/services/audience/topics/AudienceTopics.java new file mode 100644 index 0000000..0c6736f --- /dev/null +++ b/src/main/java/com/lettr/services/audience/topics/AudienceTopics.java @@ -0,0 +1,68 @@ +package com.lettr.services.audience.topics; + +import com.lettr.core.exception.LettrException; +import com.lettr.services.BaseService; +import com.lettr.services.audience.model.PageParams; +import com.lettr.services.audience.topics.model.AudienceTopicView; +import com.lettr.services.audience.topics.model.CreateAudienceTopicOptions; +import com.lettr.services.audience.topics.model.ListAudienceTopicsResponse; +import com.lettr.services.audience.topics.model.UpdateAudienceTopicOptions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Service for managing audience topics. + */ +public class AudienceTopics extends BaseService { + + public AudienceTopics(@Nonnull String apiKey) { + super(apiKey); + } + + @Nonnull + public ListAudienceTopicsResponse list() throws LettrException { + return list(null); + } + + @Nonnull + public ListAudienceTopicsResponse list(@Nullable PageParams params) throws LettrException { + return httpClient.get("/audience/topics", + params != null ? params.toQueryParams() : null, + ListAudienceTopicsResponse.class); + } + + @Nonnull + public AudienceTopicView get(@Nonnull String topicId) throws LettrException { + if (topicId == null || topicId.isEmpty()) { + throw new IllegalArgumentException("topicId is required"); + } + return httpClient.get("/audience/topics/" + topicId, null, AudienceTopicView.class); + } + + @Nonnull + public AudienceTopicView create(@Nonnull CreateAudienceTopicOptions options) throws LettrException { + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.post("/audience/topics", options, AudienceTopicView.class); + } + + @Nonnull + public AudienceTopicView update(@Nonnull String topicId, @Nonnull UpdateAudienceTopicOptions options) throws LettrException { + if (topicId == null || topicId.isEmpty()) { + throw new IllegalArgumentException("topicId is required"); + } + if (options == null) { + throw new IllegalArgumentException("options is required"); + } + return httpClient.patch("/audience/topics/" + topicId, options, AudienceTopicView.class); + } + + public void delete(@Nonnull String topicId) throws LettrException { + if (topicId == null || topicId.isEmpty()) { + throw new IllegalArgumentException("topicId is required"); + } + httpClient.delete("/audience/topics/" + topicId); + } +} diff --git a/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicDefaultSubscription.java b/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicDefaultSubscription.java new file mode 100644 index 0000000..5db6aa0 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicDefaultSubscription.java @@ -0,0 +1,8 @@ +package com.lettr.services.audience.topics.model; + +import com.google.gson.annotations.SerializedName; + +public enum AudienceTopicDefaultSubscription { + @SerializedName("opt_in") OPT_IN, + @SerializedName("opt_out") OPT_OUT +} diff --git a/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicView.java b/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicView.java new file mode 100644 index 0000000..aaac6dd --- /dev/null +++ b/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicView.java @@ -0,0 +1,37 @@ +package com.lettr.services.audience.topics.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class AudienceTopicView { + + private String id; + private String name; + private String description; + + @SerializedName("default_subscription") + private AudienceTopicDefaultSubscription defaultSubscription; + + private AudienceTopicVisibility visibility; + + @SerializedName("contacts_count") + private int contactsCount; + + @SerializedName("created_at") + private String createdAt; + + @Nonnull public String getId() { return id; } + @Nonnull public String getName() { return name; } + @Nullable public String getDescription() { return description; } + @Nonnull public AudienceTopicDefaultSubscription getDefaultSubscription() { return defaultSubscription; } + @Nonnull public AudienceTopicVisibility getVisibility() { return visibility; } + public int getContactsCount() { return contactsCount; } + @Nonnull public String getCreatedAt() { return createdAt; } + + @Override + public String toString() { + return "AudienceTopicView{id='" + id + "', name='" + name + "', visibility=" + visibility + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicVisibility.java b/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicVisibility.java new file mode 100644 index 0000000..5b54bde --- /dev/null +++ b/src/main/java/com/lettr/services/audience/topics/model/AudienceTopicVisibility.java @@ -0,0 +1,8 @@ +package com.lettr.services.audience.topics.model; + +import com.google.gson.annotations.SerializedName; + +public enum AudienceTopicVisibility { + @SerializedName("private") PRIVATE, + @SerializedName("public") PUBLIC +} diff --git a/src/main/java/com/lettr/services/audience/topics/model/CreateAudienceTopicOptions.java b/src/main/java/com/lettr/services/audience/topics/model/CreateAudienceTopicOptions.java new file mode 100644 index 0000000..98a252e --- /dev/null +++ b/src/main/java/com/lettr/services/audience/topics/model/CreateAudienceTopicOptions.java @@ -0,0 +1,83 @@ +package com.lettr.services.audience.topics.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Request body for creating an audience topic. {@code defaultSubscription} + * cannot be changed after creation. + */ +public class CreateAudienceTopicOptions { + + private final String name; + private final String description; + + @SerializedName("default_subscription") + private final AudienceTopicDefaultSubscription defaultSubscription; + + private final AudienceTopicVisibility visibility; + + private CreateAudienceTopicOptions(Builder builder) { + this.name = builder.name; + this.description = builder.description; + this.defaultSubscription = builder.defaultSubscription; + this.visibility = builder.visibility; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull public String getName() { return name; } + @Nullable public String getDescription() { return description; } + @Nullable public AudienceTopicDefaultSubscription getDefaultSubscription() { return defaultSubscription; } + @Nullable public AudienceTopicVisibility getVisibility() { return visibility; } + + public static class Builder { + private String name; + private String description; + private AudienceTopicDefaultSubscription defaultSubscription; + private AudienceTopicVisibility visibility; + + private Builder() {} + + /** (required) Topic name (max 255). */ + @Nonnull + public Builder name(@Nonnull String name) { + this.name = name; + return this; + } + + /** (optional) Topic description (max 1000). */ + @Nonnull + public Builder description(@Nullable String description) { + this.description = description; + return this; + } + + /** (optional) Default subscription policy (default {@code OPT_IN}). Immutable after creation. */ + @Nonnull + public Builder defaultSubscription(@Nullable AudienceTopicDefaultSubscription defaultSubscription) { + this.defaultSubscription = defaultSubscription; + return this; + } + + /** (optional) Topic visibility (default {@code PRIVATE}). */ + @Nonnull + public Builder visibility(@Nullable AudienceTopicVisibility visibility) { + this.visibility = visibility; + return this; + } + + @Nonnull + public CreateAudienceTopicOptions build() { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required"); + } + return new CreateAudienceTopicOptions(this); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java b/src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java new file mode 100644 index 0000000..364cb80 --- /dev/null +++ b/src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java @@ -0,0 +1,20 @@ +package com.lettr.services.audience.topics.model; + +import com.lettr.services.audience.model.AudiencePagination; + +import javax.annotation.Nonnull; +import java.util.List; + +public class ListAudienceTopicsResponse { + + private List topics; + private AudiencePagination pagination; + + @Nonnull public List getTopics() { return topics; } + @Nonnull public AudiencePagination getPagination() { return pagination; } + + @Override + public String toString() { + return "ListAudienceTopicsResponse{topics=" + topics + ", pagination=" + pagination + '}'; + } +} diff --git a/src/main/java/com/lettr/services/audience/topics/model/UpdateAudienceTopicOptions.java b/src/main/java/com/lettr/services/audience/topics/model/UpdateAudienceTopicOptions.java new file mode 100644 index 0000000..96106ae --- /dev/null +++ b/src/main/java/com/lettr/services/audience/topics/model/UpdateAudienceTopicOptions.java @@ -0,0 +1,62 @@ +package com.lettr.services.audience.topics.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Request body for partially updating an audience topic. Note that + * {@code defaultSubscription} cannot be changed after creation and is + * intentionally not exposed here. + */ +public class UpdateAudienceTopicOptions { + + private final String name; + private final String description; + private final AudienceTopicVisibility visibility; + + private UpdateAudienceTopicOptions(Builder builder) { + this.name = builder.name; + this.description = builder.description; + this.visibility = builder.visibility; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nullable public String getName() { return name; } + @Nullable public String getDescription() { return description; } + @Nullable public AudienceTopicVisibility getVisibility() { return visibility; } + + public static class Builder { + private String name; + private String description; + private AudienceTopicVisibility visibility; + + private Builder() {} + + @Nonnull + public Builder name(@Nullable String name) { + this.name = name; + return this; + } + + @Nonnull + public Builder description(@Nullable String description) { + this.description = description; + return this; + } + + @Nonnull + public Builder visibility(@Nullable AudienceTopicVisibility visibility) { + this.visibility = visibility; + return this; + } + + @Nonnull + public UpdateAudienceTopicOptions build() { + return new UpdateAudienceTopicOptions(this); + } + } +} diff --git a/src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java b/src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java new file mode 100644 index 0000000..320b9f0 --- /dev/null +++ b/src/test/java/com/lettr/services/audience/contacts/AudienceContactsTest.java @@ -0,0 +1,194 @@ +package com.lettr.services.audience.contacts; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.lettr.services.audience.contacts.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class AudienceContactsTest { + + private final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + .create(); + + @Test + void audienceContactViewDeserializes() { + String json = "{\"id\":\"c1\",\"email\":\"hi@example.com\",\"status\":\"subscribed\"," + + "\"properties\":{\"first_name\":\"Ada\"}," + + "\"created_at\":\"2024-01-15T10:30:00+00:00\"," + + "\"lists\":[{\"id\":\"l1\",\"name\":\"News\"}]," + + "\"topics\":[{\"id\":\"t1\",\"name\":\"Product\"}]}"; + + AudienceContactView view = gson.fromJson(json, AudienceContactView.class); + assertEquals("c1", view.getId()); + assertEquals("hi@example.com", view.getEmail()); + assertEquals(AudienceContactStatus.SUBSCRIBED, view.getStatus()); + assertEquals("Ada", view.getProperties().get("first_name")); + assertEquals(1, view.getLists().size()); + assertEquals("News", view.getLists().get(0).getName()); + assertEquals(1, view.getTopics().size()); + assertEquals("t1", view.getTopics().get(0).getId()); + } + + @Test + void listAudienceContactsResponseDeserializes() { + String json = "{\"contacts\":[]," + + "\"pagination\":{\"total\":0,\"per_page\":20,\"current_page\":1,\"last_page\":1}}"; + + ListAudienceContactsResponse response = gson.fromJson(json, ListAudienceContactsResponse.class); + assertNotNull(response.getContacts()); + assertEquals(0, response.getContacts().size()); + assertEquals(0, response.getPagination().getTotal()); + } + + @Test + void contactStatusEnumRoundtrips() { + // Verifies all 5 status values deserialize. + for (String s : Arrays.asList("subscribed", "unsubscribed", "bounced", "complained", "unverified")) { + AudienceContactStatus parsed = gson.fromJson("\"" + s + "\"", AudienceContactStatus.class); + assertNotNull(parsed); + } + } + + @Test + void bulkCreateResponseDeserializes() { + String json = "{\"created\":7,\"already_existed\":3}"; + BulkCreateAudienceContactsResponse response = gson.fromJson(json, BulkCreateAudienceContactsResponse.class); + assertEquals(7, response.getCreated()); + assertEquals(3, response.getAlreadyExisted()); + } + + @Test + void bulkAttachResponseDeserializes() { + String json = "{\"attached\":4,\"already_attached\":2,\"total_pairs\":6}"; + BulkAttachContactsResponse response = gson.fromJson(json, BulkAttachContactsResponse.class); + assertEquals(4, response.getAttached()); + assertEquals(2, response.getAlreadyAttached()); + assertEquals(6, response.getTotalPairs()); + } + + @Test + void bulkDetachResponseDeserializes() { + String json = "{\"detached\":1,\"not_present\":5,\"total_pairs\":6}"; + BulkDetachContactsResponse response = gson.fromJson(json, BulkDetachContactsResponse.class); + assertEquals(1, response.getDetached()); + assertEquals(5, response.getNotPresent()); + assertEquals(6, response.getTotalPairs()); + } + + @Test + void createOptionsRequiresEmail() { + assertThrows(IllegalArgumentException.class, + () -> CreateAudienceContactOptions.builder().build()); + assertThrows(IllegalArgumentException.class, + () -> CreateAudienceContactOptions.builder().email("").build()); + + Map props = new LinkedHashMap<>(); + props.put("first_name", "Ada"); + CreateAudienceContactOptions ok = CreateAudienceContactOptions.builder() + .email("ada@example.com") + .listId("l1") + .properties(props) + .build(); + assertEquals("ada@example.com", ok.getEmail()); + assertEquals("l1", ok.getListId()); + assertEquals("Ada", ok.getProperties().get("first_name")); + } + + @Test + void doubleOptInBuilderValidatesRequiredFields() { + assertThrows(IllegalArgumentException.class, () -> DoubleOptInConfig.builder().build()); + assertThrows(IllegalArgumentException.class, () -> DoubleOptInConfig.builder() + .from("a@b.com").subject("s").templateSlug("t").build()); // missing redirectUrl + + DoubleOptInConfig ok = DoubleOptInConfig.builder() + .from("a@b.com") + .subject("Confirm") + .templateSlug("confirm-template") + .redirectUrl("https://example.com/thanks") + .build(); + assertEquals("a@b.com", ok.getFrom()); + assertEquals("Confirm", ok.getSubject()); + } + + @Test + void bulkCreateOptionsValidatesEmailsBounds() { + assertThrows(IllegalArgumentException.class, + () -> BulkCreateAudienceContactsOptions.builder().emails(Collections.emptyList()).build()); + assertNotNull(BulkCreateAudienceContactsOptions.builder() + .emails(Arrays.asList("a@b.com", "c@d.com")).build()); + } + + @Test + void bulkContactListsOptionsValidatesBounds() { + assertThrows(IllegalArgumentException.class, + () -> BulkContactListsOptions.of(Collections.emptyList(), Arrays.asList("l1"))); + assertThrows(IllegalArgumentException.class, + () -> BulkContactListsOptions.of(Arrays.asList("c1"), Collections.emptyList())); + assertNotNull(BulkContactListsOptions.of(Arrays.asList("c1"), Arrays.asList("l1"))); + } + + @Test + void updateOptionsSerializesNullPropertyValuesAsJsonNull() { + // The API removes a property when the value is JSON null. Default Gson + // drops null map values, which would silently turn the deletion into a no-op. + Map props = new LinkedHashMap<>(); + props.put("keep_me", "still here"); + props.put("delete_me", null); + + UpdateAudienceContactOptions options = UpdateAudienceContactOptions.builder() + .properties(props) + .build(); + + String json = gson.toJson(options); + assertTrue(json.contains("\"delete_me\":null"), + "expected delete_me to serialize as JSON null, got: " + json); + assertTrue(json.contains("\"keep_me\":\"still here\""), json); + } + + @Test + void updateOptionsRejectsServerManagedStatuses() { + // API only accepts subscribed/unsubscribed when updating a contact. + for (AudienceContactStatus invalid : new AudienceContactStatus[]{ + AudienceContactStatus.BOUNCED, + AudienceContactStatus.COMPLAINED, + AudienceContactStatus.UNVERIFIED}) { + assertThrows(IllegalArgumentException.class, + () -> UpdateAudienceContactOptions.builder().status(invalid).build(), + "status " + invalid + " should be rejected"); + } + + UpdateAudienceContactOptions ok = UpdateAudienceContactOptions.builder() + .status(AudienceContactStatus.SUBSCRIBED).build(); + assertEquals(AudienceContactStatus.SUBSCRIBED, ok.getStatus()); + + UpdateAudienceContactOptions nullOk = UpdateAudienceContactOptions.builder() + .status(null).build(); + assertNull(nullOk.getStatus()); + } + + @Test + void serviceArgumentValidation() { + AudienceContacts svc = new AudienceContacts("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.get(null)); + assertThrows(IllegalArgumentException.class, () -> svc.get("")); + assertThrows(IllegalArgumentException.class, () -> svc.delete(null)); + assertThrows(IllegalArgumentException.class, () -> svc.create(null)); + assertThrows(IllegalArgumentException.class, () -> svc.bulkCreate(null)); + assertThrows(IllegalArgumentException.class, () -> svc.update(null, null)); + assertThrows(IllegalArgumentException.class, () -> svc.attachToList(null, "l1")); + assertThrows(IllegalArgumentException.class, () -> svc.attachToList("c1", "")); + assertThrows(IllegalArgumentException.class, () -> svc.detachFromList("", "l1")); + assertThrows(IllegalArgumentException.class, () -> svc.subscribeToTopic("c1", null)); + assertThrows(IllegalArgumentException.class, () -> svc.unsubscribeFromTopic(null, "t1")); + assertThrows(IllegalArgumentException.class, () -> svc.bulkAttachToLists(null)); + assertThrows(IllegalArgumentException.class, () -> svc.bulkDetachFromLists(null)); + } +} diff --git a/src/test/java/com/lettr/services/audience/lists/AudienceListsTest.java b/src/test/java/com/lettr/services/audience/lists/AudienceListsTest.java new file mode 100644 index 0000000..d0d761f --- /dev/null +++ b/src/test/java/com/lettr/services/audience/lists/AudienceListsTest.java @@ -0,0 +1,107 @@ +package com.lettr.services.audience.lists; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.lettr.services.audience.lists.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +class AudienceListsTest { + + private final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + .create(); + + @Test + void audienceListViewDeserializes() { + String json = "{\"id\":\"00000000-0000-0000-0000-000000000001\"," + + "\"name\":\"Newsletter\",\"contacts_count\":42}"; + + AudienceListView view = gson.fromJson(json, AudienceListView.class); + assertEquals("00000000-0000-0000-0000-000000000001", view.getId()); + assertEquals("Newsletter", view.getName()); + assertEquals(42, view.getContactsCount()); + } + + @Test + void listAudienceListsResponseDeserializes() { + String json = "{\"lists\":[{\"id\":\"abc\",\"name\":\"L1\",\"contacts_count\":1}]," + + "\"pagination\":{\"total\":1,\"per_page\":20,\"current_page\":1,\"last_page\":1}}"; + + ListAudienceListsResponse response = gson.fromJson(json, ListAudienceListsResponse.class); + assertEquals(1, response.getLists().size()); + assertEquals("L1", response.getLists().get(0).getName()); + assertEquals(1, response.getPagination().getTotal()); + assertEquals(20, response.getPagination().getPerPage()); + assertEquals(1, response.getPagination().getCurrentPage()); + assertEquals(1, response.getPagination().getLastPage()); + } + + @Test + void bulkDeleteResponseDeserializes() { + String json = "{\"deleted\":5}"; + BulkDeleteAudienceListsResponse response = gson.fromJson(json, BulkDeleteAudienceListsResponse.class); + assertEquals(5, response.getDeleted()); + } + + @Test + void createOptionsRequiresName() { + assertThrows(IllegalArgumentException.class, () -> CreateAudienceListOptions.of(null)); + assertThrows(IllegalArgumentException.class, () -> CreateAudienceListOptions.of("")); + assertNotNull(CreateAudienceListOptions.of("ok")); + } + + @Test + void bulkDeleteOptionsValidatesBounds() { + assertThrows(IllegalArgumentException.class, () -> BulkDeleteAudienceListsOptions.of(null)); + assertThrows(IllegalArgumentException.class, () -> BulkDeleteAudienceListsOptions.of(Collections.emptyList())); + assertNotNull(BulkDeleteAudienceListsOptions.of(Arrays.asList("a", "b"))); + } + + @Test + void updateOptionsBuilderAllowsEmpty() { + UpdateAudienceListOptions options = UpdateAudienceListOptions.builder().build(); + assertNull(options.getName()); + UpdateAudienceListOptions named = UpdateAudienceListOptions.builder().name("New").build(); + assertEquals("New", named.getName()); + } + + @Test + void getRequiresListId() { + AudienceLists svc = new AudienceLists("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.get(null)); + assertThrows(IllegalArgumentException.class, () -> svc.get("")); + } + + @Test + void updateRequiresListIdAndOptions() { + AudienceLists svc = new AudienceLists("test-key"); + UpdateAudienceListOptions opts = UpdateAudienceListOptions.builder().name("x").build(); + assertThrows(IllegalArgumentException.class, () -> svc.update(null, opts)); + assertThrows(IllegalArgumentException.class, () -> svc.update("", opts)); + assertThrows(IllegalArgumentException.class, () -> svc.update("id", null)); + } + + @Test + void deleteRequiresListId() { + AudienceLists svc = new AudienceLists("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.delete(null)); + assertThrows(IllegalArgumentException.class, () -> svc.delete("")); + } + + @Test + void createRequiresOptions() { + AudienceLists svc = new AudienceLists("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.create(null)); + } + + @Test + void bulkDeleteRequiresOptions() { + AudienceLists svc = new AudienceLists("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.bulkDelete(null)); + } +} diff --git a/src/test/java/com/lettr/services/audience/properties/AudiencePropertiesTest.java b/src/test/java/com/lettr/services/audience/properties/AudiencePropertiesTest.java new file mode 100644 index 0000000..5c3fc12 --- /dev/null +++ b/src/test/java/com/lettr/services/audience/properties/AudiencePropertiesTest.java @@ -0,0 +1,104 @@ +package com.lettr.services.audience.properties; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.lettr.services.audience.properties.model.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AudiencePropertiesTest { + + private final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + .create(); + + @Test + void audiencePropertyViewDeserializes() { + String json = "{\"id\":\"p1\",\"name\":\"first_name\",\"type\":\"string\"," + + "\"fallback_value\":\"there\",\"created_at\":\"2024-01-15T10:30:00+00:00\"}"; + + AudiencePropertyView view = gson.fromJson(json, AudiencePropertyView.class); + assertEquals("p1", view.getId()); + assertEquals("first_name", view.getName()); + assertEquals(AudiencePropertyType.STRING, view.getType()); + assertEquals("there", view.getFallbackValue()); + } + + @Test + void audiencePropertyViewWithNullFallback() { + String json = "{\"id\":\"p2\",\"name\":\"signup_count\",\"type\":\"number\"," + + "\"fallback_value\":null,\"created_at\":\"2024-01-15T10:30:00+00:00\"}"; + + AudiencePropertyView view = gson.fromJson(json, AudiencePropertyView.class); + assertEquals(AudiencePropertyType.NUMBER, view.getType()); + assertNull(view.getFallbackValue()); + } + + @Test + void listResponseDeserializes() { + String json = "{\"properties\":[{\"id\":\"p1\",\"name\":\"first_name\"," + + "\"type\":\"string\",\"fallback_value\":null," + + "\"created_at\":\"2024-01-15T10:30:00+00:00\"}]," + + "\"pagination\":{\"total\":1,\"per_page\":20,\"current_page\":1,\"last_page\":1}}"; + + ListAudiencePropertiesResponse response = gson.fromJson(json, ListAudiencePropertiesResponse.class); + assertEquals(1, response.getProperties().size()); + assertEquals(AudiencePropertyType.STRING, response.getProperties().get(0).getType()); + } + + @Test + void createOptionsRequiresNameAndType() { + assertThrows(IllegalArgumentException.class, + () -> CreateAudiencePropertyOptions.builder().build()); + assertThrows(IllegalArgumentException.class, + () -> CreateAudiencePropertyOptions.builder().name("first_name").build()); + assertThrows(IllegalArgumentException.class, + () -> CreateAudiencePropertyOptions.builder().type(AudiencePropertyType.STRING).build()); + + CreateAudiencePropertyOptions ok = CreateAudiencePropertyOptions.builder() + .name("first_name") + .type(AudiencePropertyType.STRING) + .fallbackValue("there") + .build(); + assertEquals("first_name", ok.getName()); + assertEquals(AudiencePropertyType.STRING, ok.getType()); + assertEquals("there", ok.getFallbackValue()); + } + + @Test + void updateOptionsExposesOnlyFallback() { + UpdateAudiencePropertyOptions cleared = UpdateAudiencePropertyOptions.withFallbackValue(null); + assertNull(cleared.getFallbackValue()); + + UpdateAudiencePropertyOptions set = UpdateAudiencePropertyOptions.withFallbackValue("hello"); + assertEquals("hello", set.getFallbackValue()); + } + + @Test + void updateOptionsSerializesNullFallbackAsJsonNull() { + // To CLEAR the fallback, the API requires {"fallback_value": null}. + // The default Gson behaviour drops null fields, so without the custom + // adapter the PATCH would be {} and the server would return the + // property unchanged. + String jsonNull = gson.toJson(UpdateAudiencePropertyOptions.withFallbackValue(null)); + assertEquals("{\"fallback_value\":null}", jsonNull); + + String jsonValue = gson.toJson(UpdateAudiencePropertyOptions.withFallbackValue("hi")); + assertEquals("{\"fallback_value\":\"hi\"}", jsonValue); + } + + @Test + void serviceArgumentValidation() { + AudienceProperties svc = new AudienceProperties("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.get(null)); + assertThrows(IllegalArgumentException.class, () -> svc.get("")); + assertThrows(IllegalArgumentException.class, () -> svc.create(null)); + assertThrows(IllegalArgumentException.class, () -> svc.update(null, null)); + assertThrows(IllegalArgumentException.class, + () -> svc.update("", UpdateAudiencePropertyOptions.withFallbackValue("x"))); + assertThrows(IllegalArgumentException.class, () -> svc.update("id", null)); + assertThrows(IllegalArgumentException.class, () -> svc.delete(null)); + assertThrows(IllegalArgumentException.class, () -> svc.delete("")); + } +} diff --git a/src/test/java/com/lettr/services/audience/segments/AudienceSegmentsTest.java b/src/test/java/com/lettr/services/audience/segments/AudienceSegmentsTest.java new file mode 100644 index 0000000..b1428f4 --- /dev/null +++ b/src/test/java/com/lettr/services/audience/segments/AudienceSegmentsTest.java @@ -0,0 +1,138 @@ +package com.lettr.services.audience.segments; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.lettr.services.audience.segments.model.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AudienceSegmentsTest { + + private final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + .create(); + + @Test + void audienceSegmentViewDeserializes() { + String json = "{\"id\":\"s1\",\"name\":\"VIPs\",\"list_id\":\"l1\",\"list_name\":\"News\"," + + "\"condition_groups\":[{\"conditions\":[" + + "{\"field\":\"email\",\"operator\":\"contains\",\"value\":\"@acme.com\"}]}]," + + "\"cached_contacts_count\":17," + + "\"created_at\":\"2024-01-15T10:30:00+00:00\"}"; + + AudienceSegmentView view = gson.fromJson(json, AudienceSegmentView.class); + assertEquals("s1", view.getId()); + assertEquals("VIPs", view.getName()); + assertEquals("l1", view.getListId()); + assertEquals("News", view.getListName()); + assertEquals(17, view.getCachedContactsCount()); + assertEquals(1, view.getConditionGroups().size()); + SegmentCondition cond = view.getConditionGroups().get(0).getConditions().get(0); + assertEquals("email", cond.getField()); + assertEquals(SegmentOperator.CONTAINS, cond.getOperator()); + assertEquals("@acme.com", cond.getValue()); + } + + @Test + void audienceSegmentViewHandlesNulls() { + String json = "{\"id\":\"s2\",\"name\":\"All\",\"list_id\":null,\"list_name\":null," + + "\"condition_groups\":[{\"conditions\":[" + + "{\"field\":\"active\",\"operator\":\"is_true\",\"value\":null}]}]," + + "\"cached_contacts_count\":null," + + "\"created_at\":\"2024-01-15T10:30:00+00:00\"}"; + + AudienceSegmentView view = gson.fromJson(json, AudienceSegmentView.class); + assertNull(view.getListId()); + assertNull(view.getListName()); + assertNull(view.getCachedContactsCount()); + assertNull(view.getConditionGroups().get(0).getConditions().get(0).getValue()); + assertEquals(SegmentOperator.IS_TRUE, + view.getConditionGroups().get(0).getConditions().get(0).getOperator()); + } + + @Test + void listResponseDeserializes() { + String json = "{\"segments\":[]," + + "\"pagination\":{\"total\":0,\"per_page\":20,\"current_page\":1,\"last_page\":1}}"; + ListAudienceSegmentsResponse response = gson.fromJson(json, ListAudienceSegmentsResponse.class); + assertEquals(0, response.getSegments().size()); + } + + @Test + void conditionFactoriesValidateInputs() { + assertThrows(IllegalArgumentException.class, + () -> SegmentCondition.of(null, SegmentOperator.EQUALS, "x")); + assertThrows(IllegalArgumentException.class, + () -> SegmentCondition.of("", SegmentOperator.EQUALS, "x")); + assertThrows(IllegalArgumentException.class, + () -> SegmentCondition.of("field", null, "x")); + + SegmentCondition cond = SegmentCondition.of("email", SegmentOperator.EQUALS, "a@b.com"); + assertEquals("email", cond.getField()); + + SegmentCondition flag = SegmentCondition.of("active", SegmentOperator.IS_TRUE); + assertNull(flag.getValue()); + } + + @Test + void conditionGroupFactoryValidatesNonEmpty() { + assertThrows(IllegalArgumentException.class, () -> SegmentConditionGroup.of((SegmentCondition[]) null)); + SegmentConditionGroup group = SegmentConditionGroup.of( + SegmentCondition.of("email", SegmentOperator.CONTAINS, "@acme.com")); + assertEquals(1, group.getConditions().size()); + } + + @Test + void conditionsInputFactoryValidatesNonEmpty() { + assertThrows(IllegalArgumentException.class, () -> SegmentConditionsInput.of((SegmentConditionGroup[]) null)); + SegmentConditionsInput input = SegmentConditionsInput.of( + SegmentConditionGroup.of( + SegmentCondition.of("email", SegmentOperator.EQUALS, "x"))); + assertEquals(1, input.getGroups().size()); + } + + @Test + void createOptionsRequiresNameAndConditions() { + SegmentConditionsInput conds = SegmentConditionsInput.of( + SegmentConditionGroup.of( + SegmentCondition.of("email", SegmentOperator.EQUALS, "x"))); + + assertThrows(IllegalArgumentException.class, + () -> CreateAudienceSegmentOptions.builder().build()); + assertThrows(IllegalArgumentException.class, + () -> CreateAudienceSegmentOptions.builder().name("Seg").build()); + assertThrows(IllegalArgumentException.class, + () -> CreateAudienceSegmentOptions.builder().conditions(conds).build()); + + CreateAudienceSegmentOptions ok = CreateAudienceSegmentOptions.builder() + .name("Seg") + .conditions(conds) + .listId("l1") + .build(); + assertEquals("Seg", ok.getName()); + assertEquals("l1", ok.getListId()); + assertNotNull(ok.getConditions()); + } + + @Test + void updateOptionsAllowsEmpty() { + UpdateAudienceSegmentOptions empty = UpdateAudienceSegmentOptions.builder().build(); + assertNull(empty.getName()); + assertNull(empty.getConditions()); + } + + @Test + void serviceArgumentValidation() { + AudienceSegments svc = new AudienceSegments("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.get(null)); + assertThrows(IllegalArgumentException.class, () -> svc.get("")); + assertThrows(IllegalArgumentException.class, () -> svc.create(null)); + assertThrows(IllegalArgumentException.class, () -> svc.update(null, null)); + assertThrows(IllegalArgumentException.class, + () -> svc.update("", UpdateAudienceSegmentOptions.builder().build())); + assertThrows(IllegalArgumentException.class, () -> svc.update("id", null)); + assertThrows(IllegalArgumentException.class, () -> svc.delete(null)); + assertThrows(IllegalArgumentException.class, () -> svc.delete("")); + } +} diff --git a/src/test/java/com/lettr/services/audience/topics/AudienceTopicsTest.java b/src/test/java/com/lettr/services/audience/topics/AudienceTopicsTest.java new file mode 100644 index 0000000..d30eb5b --- /dev/null +++ b/src/test/java/com/lettr/services/audience/topics/AudienceTopicsTest.java @@ -0,0 +1,94 @@ +package com.lettr.services.audience.topics; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.lettr.services.audience.topics.model.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AudienceTopicsTest { + + private final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + .create(); + + @Test + void audienceTopicViewDeserializes() { + String json = "{\"id\":\"t1\",\"name\":\"Product\",\"description\":\"Product updates\"," + + "\"default_subscription\":\"opt_in\",\"visibility\":\"public\"," + + "\"contacts_count\":12,\"created_at\":\"2024-01-15T10:30:00+00:00\"}"; + + AudienceTopicView view = gson.fromJson(json, AudienceTopicView.class); + assertEquals("t1", view.getId()); + assertEquals("Product", view.getName()); + assertEquals("Product updates", view.getDescription()); + assertEquals(AudienceTopicDefaultSubscription.OPT_IN, view.getDefaultSubscription()); + assertEquals(AudienceTopicVisibility.PUBLIC, view.getVisibility()); + assertEquals(12, view.getContactsCount()); + } + + @Test + void audienceTopicViewWithNullDescription() { + String json = "{\"id\":\"t1\",\"name\":\"Sales\",\"description\":null," + + "\"default_subscription\":\"opt_out\",\"visibility\":\"private\"," + + "\"contacts_count\":0,\"created_at\":\"2024-01-15T10:30:00+00:00\"}"; + + AudienceTopicView view = gson.fromJson(json, AudienceTopicView.class); + assertNull(view.getDescription()); + assertEquals(AudienceTopicDefaultSubscription.OPT_OUT, view.getDefaultSubscription()); + assertEquals(AudienceTopicVisibility.PRIVATE, view.getVisibility()); + } + + @Test + void listResponseDeserializes() { + String json = "{\"topics\":[]," + + "\"pagination\":{\"total\":0,\"per_page\":20,\"current_page\":1,\"last_page\":1}}"; + ListAudienceTopicsResponse response = gson.fromJson(json, ListAudienceTopicsResponse.class); + assertEquals(0, response.getTopics().size()); + } + + @Test + void createOptionsRequiresName() { + assertThrows(IllegalArgumentException.class, + () -> CreateAudienceTopicOptions.builder().build()); + assertThrows(IllegalArgumentException.class, + () -> CreateAudienceTopicOptions.builder().name("").build()); + + CreateAudienceTopicOptions ok = CreateAudienceTopicOptions.builder() + .name("Topic") + .description("Desc") + .defaultSubscription(AudienceTopicDefaultSubscription.OPT_IN) + .visibility(AudienceTopicVisibility.PUBLIC) + .build(); + assertEquals("Topic", ok.getName()); + assertEquals(AudienceTopicVisibility.PUBLIC, ok.getVisibility()); + } + + @Test + void updateOptionsAllowsEmptyAndOmitsDefaultSubscription() { + UpdateAudienceTopicOptions empty = UpdateAudienceTopicOptions.builder().build(); + assertNull(empty.getName()); + assertNull(empty.getVisibility()); + + UpdateAudienceTopicOptions updated = UpdateAudienceTopicOptions.builder() + .name("Renamed") + .visibility(AudienceTopicVisibility.PRIVATE) + .build(); + assertEquals("Renamed", updated.getName()); + } + + @Test + void serviceArgumentValidation() { + AudienceTopics svc = new AudienceTopics("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.get(null)); + assertThrows(IllegalArgumentException.class, () -> svc.get("")); + assertThrows(IllegalArgumentException.class, () -> svc.create(null)); + assertThrows(IllegalArgumentException.class, () -> svc.update(null, null)); + assertThrows(IllegalArgumentException.class, + () -> svc.update("", UpdateAudienceTopicOptions.builder().build())); + assertThrows(IllegalArgumentException.class, () -> svc.update("id", null)); + assertThrows(IllegalArgumentException.class, () -> svc.delete(null)); + assertThrows(IllegalArgumentException.class, () -> svc.delete("")); + } +}