From cbd0aa2218875f6cd7a219da0f90c9731c62b1d3 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Wed, 6 May 2026 16:20:15 -0600 Subject: [PATCH 1/9] test(refactor): add createTestTicket() move things around to be more scannable --- src/test/groovy/lol/pbu/z4j/Z4jSpec.groovy | 15 ++++++++- .../pbu/z4j/client/SearchClientSpec.groovy | 33 ++++++++++++------- .../pbu/z4j/client/TicketClientSpec.groovy | 4 +-- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/test/groovy/lol/pbu/z4j/Z4jSpec.groovy b/src/test/groovy/lol/pbu/z4j/Z4jSpec.groovy index 07510fe..39799da 100644 --- a/src/test/groovy/lol/pbu/z4j/Z4jSpec.groovy +++ b/src/test/groovy/lol/pbu/z4j/Z4jSpec.groovy @@ -19,7 +19,8 @@ import io.micronaut.context.ApplicationContext import io.micronaut.runtime.server.EmbeddedServer import io.micronaut.test.extensions.spock.annotation.MicronautTest import lol.pbu.z4j.client.LocaleClient -import lol.pbu.z4j.model.Locale +import lol.pbu.z4j.client.TicketClient +import lol.pbu.z4j.model.* import net.datafaker.Faker import spock.lang.Shared import spock.lang.Specification @@ -31,6 +32,9 @@ class Z4jSpec extends Specification { @Shared ApplicationContext adminCtx, agentCtx, userCtx, badTokenCtx, badEmailCtx, badUrlCtx + @Shared + TicketClient ticketsAdminClient + @Shared List accountLocales @@ -57,6 +61,15 @@ class Z4jSpec extends Specification { badUrlCtx?.stop() } + TicketResponse createTicketForTest() { + ticketsAdminClient = ticketsAdminClient ?: adminCtx.getBean(TicketClient.class) + TicketComment ticketComment = new TicketComment().setBody(faker.chuckNorris().fact()) + TicketCreateInput createTicketInput = new TicketCreateInput(ticketComment) + createTicketInput.setRawSubject(faker.chuckNorris().fact()) + TicketCreateRequest createTicketRequest = new TicketCreateRequest(createTicketInput) + return ticketsAdminClient.createTicket(createTicketRequest).block() + } + static ApplicationContext getCtx(String authUser) { return getCtx(authUser, [:]) } diff --git a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy index c810bf0..2dd507b 100644 --- a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy +++ b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy @@ -34,6 +34,8 @@ class SearchClientSpec extends Z4jSpec { userSearchClient = userCtx.getBean(SearchClient.class) } + /* ---------- list() tests --------------- */ + @SuppressWarnings("GroovyAssignabilityCheck") @Unroll("an #clientName user can run the list method with sortby: #sortBy, sortOrder: #sortOrder and include: #include") void "can run the list method"(String clientName, SearchClient client, SortBy sortBy, SortOrder sortOrder, String include) { @@ -50,18 +52,6 @@ class SearchClientSpec extends Z4jSpec { [null, faker.cat().name()]].combinations() } - void "an #clientName user can run the count method"(String clientName, SearchClient client) { - when: - client.count(faker.bluey().quote()).block() - - then: - noExceptionThrown() - - where: - [client, clientName] << [[adminSearchClient, "admin"], [agentSearchClient, "agent"]] - } - - @SuppressWarnings("GroovyAssignabilityCheck") @Unroll("an #clientName user can paginate the list method with sortby: #sortBy, sortOrder: #sortOrder and include: #include") void "can query the list method when results include more than the page size"( @@ -116,6 +106,20 @@ class SearchClientSpec extends Z4jSpec { [null, faker.cat().name()]].combinations() } + /* ---------- count() tests --------------- */ + + void "an #clientName user can run the count method"(String clientName, SearchClient client) { + when: + client.count(faker.bluey().quote()).block() + + then: + noExceptionThrown() + + where: + [client, clientName] << [[adminSearchClient, "admin"], [agentSearchClient, "agent"]] + } + + void "a normal user cannot run the count method"(SearchClient client) { when: client.count(faker.bluey().quote()).block() @@ -128,9 +132,14 @@ class SearchClientSpec extends Z4jSpec { userSearchClient | _ } + /* ---------- export() tests --------------- */ + @SuppressWarnings("GroovyAssignabilityCheck") void "an #clientName can call export method with pageSize: #pageSize, pageAfter: #pageAfter, filterType: #filterType and include: #include"( String clientName, SearchClient client, int pageSize, String pageAfter, SearchExportType filterType, String include) { + given: + client.count("type:ticket").block() + when: client.export(faker.bluey().quote(), pageSize, pageAfter, filterType, include).block() diff --git a/src/test/groovy/lol/pbu/z4j/client/TicketClientSpec.groovy b/src/test/groovy/lol/pbu/z4j/client/TicketClientSpec.groovy index 4b43734..b2a4fbf 100644 --- a/src/test/groovy/lol/pbu/z4j/client/TicketClientSpec.groovy +++ b/src/test/groovy/lol/pbu/z4j/client/TicketClientSpec.groovy @@ -23,7 +23,7 @@ import spock.lang.Shared class TicketClientSpec extends Z4jSpec { @Shared - TicketClient ticketsAgentClient, ticketsAdminClient, ticketsUserClient, ticketBadEmailClient, ticketBadUrlClient + TicketClient ticketsAgentClient, ticketsUserClient, ticketBadEmailClient, ticketBadUrlClient @Shared List tickets @@ -35,7 +35,7 @@ class TicketClientSpec extends Z4jSpec { ticketBadEmailClient = badEmailCtx.getBean(TicketClient.class) ticketBadUrlClient = badUrlCtx.getBean(TicketClient.class) ticketsAgentClient = agentCtx.getBean(TicketClient.class) - ticketsAdminClient = adminCtx.getBean(TicketClient.class) + ticketsAdminClient = ticketsAdminClient ?: adminCtx.getBean(TicketClient.class) ticketsUserClient = userCtx.getBean(TicketClient.class) tickets = ticketsAgentClient.listTickets(null).block().getTickets() clientTestMatrix = [[client: ticketsAgentClient, clientType: "Agent", shouldSucceed: true, expectedTitle: "should"], From 1b77c93035eb4897e8f41a12ce87505d4545c17c Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Wed, 6 May 2026 21:37:40 -0600 Subject: [PATCH 2/9] fix: allow result ID to be larger than int values --- .../java/lol/pbu/z4j/model/SearchResult.java | 2 +- .../pbu/z4j/client/SearchClientSpec.groovy | 42 ++++++------------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/src/main/java/lol/pbu/z4j/model/SearchResult.java b/src/main/java/lol/pbu/z4j/model/SearchResult.java index ee1ca63..d3a5b5d 100644 --- a/src/main/java/lol/pbu/z4j/model/SearchResult.java +++ b/src/main/java/lol/pbu/z4j/model/SearchResult.java @@ -97,7 +97,7 @@ public class SearchResult { @Nullable @JsonProperty(JSON_PROPERTY_ID) @JsonInclude(JsonInclude.Include.USE_DEFAULTS) - private Integer id; + private Long id; /** * The name of the resource diff --git a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy index 2dd507b..2563772 100644 --- a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy +++ b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy @@ -17,7 +17,10 @@ package lol.pbu.z4j.client import io.micronaut.http.client.exceptions.HttpClientResponseException import lol.pbu.z4j.Z4jSpec -import lol.pbu.z4j.model.* +import lol.pbu.z4j.model.SearchExportType +import lol.pbu.z4j.model.SearchResponse +import lol.pbu.z4j.model.SortBy +import lol.pbu.z4j.model.SortOrder import spock.lang.Shared import spock.lang.Unroll @@ -25,9 +28,6 @@ class SearchClientSpec extends Z4jSpec { @Shared SearchClient adminSearchClient, agentSearchClient, userSearchClient - @Shared - TicketClient ticketClient - def setupSpec() { adminSearchClient = adminCtx.getBean(SearchClient.class) agentSearchClient = agentCtx.getBean(SearchClient.class) @@ -40,7 +40,7 @@ class SearchClientSpec extends Z4jSpec { @Unroll("an #clientName user can run the list method with sortby: #sortBy, sortOrder: #sortOrder and include: #include") void "can run the list method"(String clientName, SearchClient client, SortBy sortBy, SortOrder sortOrder, String include) { when: - client.list(faker.bluey().quote(), sortBy, sortOrder, include, null, null).block() + client.list("type:group", sortBy, sortOrder, include, null, null).block() then: noExceptionThrown() @@ -52,43 +52,27 @@ class SearchClientSpec extends Z4jSpec { [null, faker.cat().name()]].combinations() } - @SuppressWarnings("GroovyAssignabilityCheck") - @Unroll("an #clientName user can paginate the list method with sortby: #sortBy, sortOrder: #sortOrder and include: #include") - void "can query the list method when results include more than the page size"( - String clientName, SearchClient client, SortBy sortBy, SortOrder sortOrder, String include) { + void "can query the list method when results include more than the page size"() { given: - if (null == ticketClient) { - ticketClient = adminCtx.getBean(TicketClient.class) - } - if (client.count("frank").block().getCount() < 5) { - (1..5).each { - TicketComment ticketComment = new TicketComment().setBody("frank " + faker.chuckNorris().fact()) - TicketCreateRequest createTicketRequest = new TicketCreateRequest(new TicketCreateInput(ticketComment)) - createTicketRequest.ticket.setSubject(faker.chuckNorris().fact()) - ticketClient.createTicket(createTicketRequest).block() - } + String ticketQuery = "type:ticket" + def ticketCount = adminSearchClient.count(ticketQuery).block().getCount() + if (ticketCount < 5) { + (ticketCount..5).each { createTicketForTest() } } when: def page = 1 List responses = [] - SearchResponse response = client.list("frank", sortBy, sortOrder, include, page, 2).block() + SearchResponse response = adminSearchClient.list(ticketQuery, null, null, null, page, 2).block() responses << response - while (response.nextPage != null) { + while (response.nextPage != null && page < 3) { + response = adminSearchClient.list(ticketQuery, null, null, null, page, 2).block() page++ - response = client.list("frank", sortBy, sortOrder, include, page, 2).block() responses << response } then: - noExceptionThrown() - - where: - [[client, clientName], sortBy, sortOrder, include] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]], - [SortBy.values(), null].flatten(), - [SortOrder.values(), null].flatten(), - [null, faker.cat().name()]].combinations() } @Unroll("a simple user querying the list method fails with #sortBy, #sortOrder and #include") From 0f0a3f1b26917e2cdb58f153dba2c3ec47e0d014 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Thu, 7 May 2026 12:46:07 -0600 Subject: [PATCH 3/9] feat: create export's return type --- .../java/lol/pbu/z4j/client/SearchClient.java | 15 ++++----- .../pbu/z4j/model/CreateResourceResult.java | 32 +++++++++++++++++++ .../lol/pbu/z4j/model/ExportResponse.java | 20 ++++++++++++ .../java/lol/pbu/z4j/model/Exportable.java | 7 ++++ src/main/java/lol/pbu/z4j/model/Links.java | 15 +++++++++ src/main/java/lol/pbu/z4j/model/Meta.java | 13 ++++++++ src/main/java/lol/pbu/z4j/model/Ticket.java | 10 ++---- 7 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 src/main/java/lol/pbu/z4j/model/CreateResourceResult.java create mode 100644 src/main/java/lol/pbu/z4j/model/ExportResponse.java create mode 100644 src/main/java/lol/pbu/z4j/model/Exportable.java create mode 100644 src/main/java/lol/pbu/z4j/model/Links.java create mode 100644 src/main/java/lol/pbu/z4j/model/Meta.java diff --git a/src/main/java/lol/pbu/z4j/client/SearchClient.java b/src/main/java/lol/pbu/z4j/client/SearchClient.java index f27f1f7..3735178 100644 --- a/src/main/java/lol/pbu/z4j/client/SearchClient.java +++ b/src/main/java/lol/pbu/z4j/client/SearchClient.java @@ -23,10 +23,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotNull; -import lol.pbu.z4j.model.SearchExportType; -import lol.pbu.z4j.model.SearchResponse; -import lol.pbu.z4j.model.SortBy; -import lol.pbu.z4j.model.SortOrder; +import lol.pbu.z4j.model.*; import reactor.core.publisher.Mono; /** @@ -95,12 +92,12 @@ public interface SearchClient { * or Error response (status code 400) */ @Get("/api/v2/search/export") - Mono<@Valid SearchResponse> export( - @QueryValue("query") @NotNull String query, - @QueryValue("page[size]") @NotNull @Max(1000) Integer pageSize, - @QueryValue("page[after]") @NotNull String pageAfter, + Mono<@Valid ExportResponse> export( + @NotNull String query, + @QueryValue("page[size]") @Nullable @Max(1000) Integer pageSize, + @QueryValue("page[after]") @Nullable String pageAfter, @QueryValue("filter[type]") @NotNull SearchExportType filterType, - @QueryValue("include") @Nullable String include + @Nullable String include ); /** diff --git a/src/main/java/lol/pbu/z4j/model/CreateResourceResult.java b/src/main/java/lol/pbu/z4j/model/CreateResourceResult.java new file mode 100644 index 0000000..d8c765a --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/CreateResourceResult.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Peanut Butter Unicorn, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lol.pbu.z4j.model; + +import io.micronaut.serde.annotation.Serdeable; +import lombok.*; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Setter +@Getter +@Serdeable +public class CreateResourceResult extends JobStatus { + private Integer id; + private Integer index; +} diff --git a/src/main/java/lol/pbu/z4j/model/ExportResponse.java b/src/main/java/lol/pbu/z4j/model/ExportResponse.java new file mode 100644 index 0000000..44bf71d --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/ExportResponse.java @@ -0,0 +1,20 @@ +package lol.pbu.z4j.model; + +import io.micronaut.context.annotation.Any; +import io.micronaut.serde.annotation.Serdeable; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.util.List; + +@Data +@Accessors(chain = true) +@EqualsAndHashCode +@Serdeable +public class ExportResponse { + private List results; + private Any facets; + private Meta meta; + private Links links; +} diff --git a/src/main/java/lol/pbu/z4j/model/Exportable.java b/src/main/java/lol/pbu/z4j/model/Exportable.java new file mode 100644 index 0000000..de1cdb8 --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/Exportable.java @@ -0,0 +1,7 @@ +package lol.pbu.z4j.model; + +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +public interface Exportable { +} diff --git a/src/main/java/lol/pbu/z4j/model/Links.java b/src/main/java/lol/pbu/z4j/model/Links.java new file mode 100644 index 0000000..92375b6 --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/Links.java @@ -0,0 +1,15 @@ +package lol.pbu.z4j.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.net.URL; + +@Data +@Accessors(chain = true) +@EqualsAndHashCode +public class Links { + private URL prev; + private URL next; +} diff --git a/src/main/java/lol/pbu/z4j/model/Meta.java b/src/main/java/lol/pbu/z4j/model/Meta.java new file mode 100644 index 0000000..181f93b --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/Meta.java @@ -0,0 +1,13 @@ +package lol.pbu.z4j.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.context.annotation.Any; + +public class Meta { + @JsonProperty("has_more") + private Boolean hasMore; + @JsonProperty("after_cursor") + private String afterCursor; + @JsonProperty("before_cursor") + private Any beforeCursor; +} diff --git a/src/main/java/lol/pbu/z4j/model/Ticket.java b/src/main/java/lol/pbu/z4j/model/Ticket.java index 691f3d9..9648757 100644 --- a/src/main/java/lol/pbu/z4j/model/Ticket.java +++ b/src/main/java/lol/pbu/z4j/model/Ticket.java @@ -22,10 +22,8 @@ import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import lombok.Data; import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; import lombok.experimental.Accessors; import java.time.ZonedDateTime; @@ -41,9 +39,7 @@ */ @Accessors(chain = true) @EqualsAndHashCode -@ToString -@Getter -@Setter +@Data @JsonPropertyOrder({ Ticket.JSON_PROPERTY_REQUESTER_ID, Ticket.JSON_PROPERTY_ALLOW_ATTACHMENTS, @@ -100,7 +96,7 @@ Ticket.JSON_PROPERTY_VOICE_COMMENT, }) @Serdeable -public class Ticket { +public class Ticket implements Exportable { public static final String JSON_PROPERTY_REQUESTER_ID = "requester_id"; public static final String JSON_PROPERTY_ALLOW_ATTACHMENTS = "allow_attachments"; From a57fe5e37243e1916f264194fd44230079303f32 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Thu, 7 May 2026 12:46:24 -0600 Subject: [PATCH 4/9] refactor: cleanup SearchExportType --- .../lol/pbu/z4j/model/SearchExportType.java | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/main/java/lol/pbu/z4j/model/SearchExportType.java b/src/main/java/lol/pbu/z4j/model/SearchExportType.java index 16b7426..6f2e511 100644 --- a/src/main/java/lol/pbu/z4j/model/SearchExportType.java +++ b/src/main/java/lol/pbu/z4j/model/SearchExportType.java @@ -28,7 +28,8 @@ import java.util.stream.Collectors; /** - * Gets or Sets SearchExportType + *

{@summary The object type returned by the export query.

+ * Can be ticket, organization, user, or group. * * @author Jonathan-Zollinger * @since 0.1.1 @@ -38,21 +39,16 @@ @Serdeable public enum SearchExportType { - @JsonProperty("ticket") - TICKET("ticket"), + @JsonProperty("ticket") TICKET("ticket"), - @JsonProperty("organization") - ORGANIZATION("organization"), + @JsonProperty("organization") ORGANIZATION("organization"), - @JsonProperty("user") - USER("user"), + @JsonProperty("user") USER("user"), - @JsonProperty("group") - GROUP("group"), + @JsonProperty("group") GROUP("group"), ; - public static final Map VALUE_MAPPING = Map.copyOf(Arrays.stream(values()) - .collect(Collectors.toMap(v -> v.value, Function.identity()))); + public static final Map VALUE_MAPPING = Map.copyOf(Arrays.stream(values()).collect(Collectors.toMap(v -> v.value, Function.identity()))); private final String value; From 980d5a88217def94a7ea5d22a508be7d3c521253 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Thu, 7 May 2026 14:26:43 -0600 Subject: [PATCH 5/9] feat: wip - working export needs custom deserializer setup --- .../java/lol/pbu/z4j/client/SearchClient.java | 6 +-- .../lol/pbu/z4j/model/ExportResponse.java | 5 +- .../java/lol/pbu/z4j/model/FailedResult.java | 20 ++++++++ src/main/java/lol/pbu/z4j/model/Group.java | 7 +++ .../java/lol/pbu/z4j/model/JobStatus.java | 24 +++++++++ .../lol/pbu/z4j/model/JobStatusResponse.java | 35 +++++++++++++ src/main/java/lol/pbu/z4j/model/Links.java | 2 + .../lol/pbu/z4j/model/LocaleAbbreviation.java | 12 +++++ src/main/java/lol/pbu/z4j/model/Meta.java | 11 ++++- .../java/lol/pbu/z4j/model/Organization.java | 7 +++ src/main/java/lol/pbu/z4j/model/Ticket.java | 4 +- .../pbu/z4j/model/UpdateResourceResult.java | 49 +++++++++++++++++++ src/main/java/lol/pbu/z4j/model/User.java | 7 +++ .../pbu/z4j/client/SearchClientSpec.groovy | 27 +++++----- 14 files changed, 193 insertions(+), 23 deletions(-) create mode 100644 src/main/java/lol/pbu/z4j/model/FailedResult.java create mode 100644 src/main/java/lol/pbu/z4j/model/Group.java create mode 100644 src/main/java/lol/pbu/z4j/model/JobStatus.java create mode 100644 src/main/java/lol/pbu/z4j/model/JobStatusResponse.java create mode 100644 src/main/java/lol/pbu/z4j/model/Organization.java create mode 100644 src/main/java/lol/pbu/z4j/model/UpdateResourceResult.java create mode 100644 src/main/java/lol/pbu/z4j/model/User.java diff --git a/src/main/java/lol/pbu/z4j/client/SearchClient.java b/src/main/java/lol/pbu/z4j/client/SearchClient.java index 3735178..4bbb1c4 100644 --- a/src/main/java/lol/pbu/z4j/client/SearchClient.java +++ b/src/main/java/lol/pbu/z4j/client/SearchClient.java @@ -92,12 +92,12 @@ public interface SearchClient { * or Error response (status code 400) */ @Get("/api/v2/search/export") - Mono<@Valid ExportResponse> export( - @NotNull String query, + Mono<@Valid ExportResponse> export( + @QueryValue("query") @NotNull String query, @QueryValue("page[size]") @Nullable @Max(1000) Integer pageSize, @QueryValue("page[after]") @Nullable String pageAfter, @QueryValue("filter[type]") @NotNull SearchExportType filterType, - @Nullable String include + @QueryValue("include") @Nullable String include ); /** diff --git a/src/main/java/lol/pbu/z4j/model/ExportResponse.java b/src/main/java/lol/pbu/z4j/model/ExportResponse.java index 44bf71d..d1199ec 100644 --- a/src/main/java/lol/pbu/z4j/model/ExportResponse.java +++ b/src/main/java/lol/pbu/z4j/model/ExportResponse.java @@ -1,6 +1,5 @@ package lol.pbu.z4j.model; -import io.micronaut.context.annotation.Any; import io.micronaut.serde.annotation.Serdeable; import lombok.Data; import lombok.EqualsAndHashCode; @@ -12,9 +11,9 @@ @Accessors(chain = true) @EqualsAndHashCode @Serdeable -public class ExportResponse { +public class ExportResponse { private List results; - private Any facets; + private String facets; private Meta meta; private Links links; } diff --git a/src/main/java/lol/pbu/z4j/model/FailedResult.java b/src/main/java/lol/pbu/z4j/model/FailedResult.java new file mode 100644 index 0000000..e41135b --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/FailedResult.java @@ -0,0 +1,20 @@ +package lol.pbu.z4j.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.serde.annotation.Serdeable; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = true) +@Serdeable +public class FailedResult extends JobStatus { + private String action; + private String details; + private String error; + @JsonProperty("id") + private Integer failedResultID; + private Boolean success; +} diff --git a/src/main/java/lol/pbu/z4j/model/Group.java b/src/main/java/lol/pbu/z4j/model/Group.java new file mode 100644 index 0000000..5cf9272 --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/Group.java @@ -0,0 +1,7 @@ +package lol.pbu.z4j.model; + +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +public class Group implements Exportable { +} diff --git a/src/main/java/lol/pbu/z4j/model/JobStatus.java b/src/main/java/lol/pbu/z4j/model/JobStatus.java new file mode 100644 index 0000000..3701697 --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/JobStatus.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Peanut Butter Unicorn, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lol.pbu.z4j.model; + +import io.micronaut.serde.annotation.Serdeable; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode +@Serdeable +public class JobStatus { +} diff --git a/src/main/java/lol/pbu/z4j/model/JobStatusResponse.java b/src/main/java/lol/pbu/z4j/model/JobStatusResponse.java new file mode 100644 index 0000000..21e2fb4 --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/JobStatusResponse.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Peanut Butter Unicorn, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lol.pbu.z4j.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.serde.annotation.Serdeable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Getter +@Accessors(chain = true) +@AllArgsConstructor +@NoArgsConstructor +@Serdeable +public class JobStatusResponse { + + @JsonProperty("job_status") + private JobStatus jobStatus; + +} diff --git a/src/main/java/lol/pbu/z4j/model/Links.java b/src/main/java/lol/pbu/z4j/model/Links.java index 92375b6..ae82157 100644 --- a/src/main/java/lol/pbu/z4j/model/Links.java +++ b/src/main/java/lol/pbu/z4j/model/Links.java @@ -1,5 +1,6 @@ package lol.pbu.z4j.model; +import io.micronaut.serde.annotation.Serdeable; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; @@ -9,6 +10,7 @@ @Data @Accessors(chain = true) @EqualsAndHashCode +@Serdeable public class Links { private URL prev; private URL next; diff --git a/src/main/java/lol/pbu/z4j/model/LocaleAbbreviation.java b/src/main/java/lol/pbu/z4j/model/LocaleAbbreviation.java index 518c3c2..f7cc772 100644 --- a/src/main/java/lol/pbu/z4j/model/LocaleAbbreviation.java +++ b/src/main/java/lol/pbu/z4j/model/LocaleAbbreviation.java @@ -59,6 +59,9 @@ public enum LocaleAbbreviation { @JsonProperty("en-us") ENGLISH_UNITED_STATES("en-us"), + @JsonProperty("en-vn") + ENGLISH_VIETNAM("en-vn"), + @JsonProperty("fa-af") DARI_PERSIAN_AFGHANISTAN("fa-af"), @@ -110,6 +113,9 @@ public enum LocaleAbbreviation { @JsonProperty("pl") POLISH("pl"), + @JsonProperty("pt") + PORTUGUESE("pt"), + @JsonProperty("ro") ROMANIAN("ro"), @@ -140,8 +146,14 @@ public enum LocaleAbbreviation { @JsonProperty("uk") UKRAINIAN("uk"), + @JsonProperty("vi-vn") + VIETNAMESE_VIETNAM("vi-vn"), + @JsonProperty("vi") VIETNAMESE("vi"), + + @JsonProperty("zu-za") + ZULU_SOUTH_AFRICA("zu-za"), ; public static final Map VALUE_MAPPING = Map.copyOf(Arrays.stream(values()) diff --git a/src/main/java/lol/pbu/z4j/model/Meta.java b/src/main/java/lol/pbu/z4j/model/Meta.java index 181f93b..a31f3af 100644 --- a/src/main/java/lol/pbu/z4j/model/Meta.java +++ b/src/main/java/lol/pbu/z4j/model/Meta.java @@ -1,13 +1,20 @@ package lol.pbu.z4j.model; import com.fasterxml.jackson.annotation.JsonProperty; -import io.micronaut.context.annotation.Any; +import io.micronaut.serde.annotation.Serdeable; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +@Data +@EqualsAndHashCode +@Accessors(chain = true) +@Serdeable public class Meta { @JsonProperty("has_more") private Boolean hasMore; @JsonProperty("after_cursor") private String afterCursor; @JsonProperty("before_cursor") - private Any beforeCursor; + private String beforeCursor; } diff --git a/src/main/java/lol/pbu/z4j/model/Organization.java b/src/main/java/lol/pbu/z4j/model/Organization.java new file mode 100644 index 0000000..1d45e4e --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/Organization.java @@ -0,0 +1,7 @@ +package lol.pbu.z4j.model; + +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +public class Organization implements Exportable { +} diff --git a/src/main/java/lol/pbu/z4j/model/Ticket.java b/src/main/java/lol/pbu/z4j/model/Ticket.java index 9648757..61fa813 100644 --- a/src/main/java/lol/pbu/z4j/model/Ticket.java +++ b/src/main/java/lol/pbu/z4j/model/Ticket.java @@ -22,8 +22,8 @@ import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; import java.time.ZonedDateTime; @@ -39,7 +39,7 @@ */ @Accessors(chain = true) @EqualsAndHashCode -@Data +@RequiredArgsConstructor @JsonPropertyOrder({ Ticket.JSON_PROPERTY_REQUESTER_ID, Ticket.JSON_PROPERTY_ALLOW_ATTACHMENTS, diff --git a/src/main/java/lol/pbu/z4j/model/UpdateResourceResult.java b/src/main/java/lol/pbu/z4j/model/UpdateResourceResult.java new file mode 100644 index 0000000..f3f5510 --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/UpdateResourceResult.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Peanut Butter Unicorn, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lol.pbu.z4j.model; + +import io.micronaut.serde.annotation.Serdeable; +import lombok.*; +import lombok.experimental.Accessors; + +@Getter +@Setter +@Accessors(chain = true) +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Serdeable +public class UpdateResourceResult extends JobStatus { + /** + * the action the job attempted ({@code "action": "update"}) + */ + private String action; + + /** + * the id of the resource the job attempted to update + */ + private Integer id; + + /** + * the status ({@code "status": "Updated"}) + */ + private String status; + + /** + * whether the action was successful or not ({@code "success": true}) + */ + private boolean success; +} diff --git a/src/main/java/lol/pbu/z4j/model/User.java b/src/main/java/lol/pbu/z4j/model/User.java new file mode 100644 index 0000000..d2c118b --- /dev/null +++ b/src/main/java/lol/pbu/z4j/model/User.java @@ -0,0 +1,7 @@ +package lol.pbu.z4j.model; + +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +public class User implements Exportable { +} diff --git a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy index 2563772..dbf46d4 100644 --- a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy +++ b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy @@ -17,10 +17,7 @@ package lol.pbu.z4j.client import io.micronaut.http.client.exceptions.HttpClientResponseException import lol.pbu.z4j.Z4jSpec -import lol.pbu.z4j.model.SearchExportType -import lol.pbu.z4j.model.SearchResponse -import lol.pbu.z4j.model.SortBy -import lol.pbu.z4j.model.SortOrder +import lol.pbu.z4j.model.* import spock.lang.Shared import spock.lang.Unroll @@ -119,22 +116,26 @@ class SearchClientSpec extends Z4jSpec { /* ---------- export() tests --------------- */ @SuppressWarnings("GroovyAssignabilityCheck") - void "an #clientName can call export method with pageSize: #pageSize, pageAfter: #pageAfter, filterType: #filterType and include: #include"( - String clientName, SearchClient client, int pageSize, String pageAfter, SearchExportType filterType, String include) { + void "an #clientName can call export method with pageSize: #pageSize, filterType: #filterType and include: #include"( + String clientName, SearchClient client, int pageSize, SearchExportType filterType, String include) { given: - client.count("type:ticket").block() + client.count("type: $filterType").block() when: - client.export(faker.bluey().quote(), pageSize, pageAfter, filterType, include).block() + ExportResponse response = client.export("type: $filterType", pageSize, null, filterType, include).block() + + and: + if (response.getLinks().getNext() != null) { + client.export("type: $filterType", pageSize, response.getMeta().getAfterCursor(), filterType, include).block() + } then: noExceptionThrown() where: - [[client, clientName], pageSize, pageAfter, filterType, include] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]], - [100], - [faker.internet().uuid()], - SearchExportType.values(), - ["organizations"]].combinations() + [[client, clientName], pageSize, filterType, include] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]], + [100], + SearchExportType.values(), + ["organizations"]].combinations() } } From 5fa7150d5fb08af5fb1f2b188438f8408ca9e931 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Thu, 7 May 2026 14:27:45 -0600 Subject: [PATCH 6/9] fix: add getters and setters to ticket --- src/main/java/lol/pbu/z4j/model/Ticket.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/lol/pbu/z4j/model/Ticket.java b/src/main/java/lol/pbu/z4j/model/Ticket.java index 61fa813..a339867 100644 --- a/src/main/java/lol/pbu/z4j/model/Ticket.java +++ b/src/main/java/lol/pbu/z4j/model/Ticket.java @@ -22,6 +22,7 @@ import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import lombok.Data; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; @@ -38,6 +39,7 @@ * @since 0.1.1 */ @Accessors(chain = true) +@Data @EqualsAndHashCode @RequiredArgsConstructor @JsonPropertyOrder({ From ba46c426636961bb9b942ed6f8c827ee9dba8ce2 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Fri, 8 May 2026 16:31:03 -0600 Subject: [PATCH 7/9] feat: introduce searchService Signed-off-by: jonathan zollinger --- src/main/java/lol/pbu/z4j/SearchService.java | 46 +++++++++++ .../java/lol/pbu/z4j/client/SearchClient.java | 21 ++--- .../lol/pbu/z4j/model/SearchExportType.java | 2 +- src/main/java/lol/pbu/z4j/model/Ticket.java | 2 +- src/main/resources/Ticketing.yaml | 28 +++---- .../lol/pbu/z4j/SearchServiceSpec.groovy | 78 +++++++++++++++++++ .../pbu/z4j/client/SearchClientSpec.groovy | 52 ++++++------- 7 files changed, 174 insertions(+), 55 deletions(-) create mode 100644 src/main/java/lol/pbu/z4j/SearchService.java create mode 100644 src/test/groovy/lol/pbu/z4j/SearchServiceSpec.groovy diff --git a/src/main/java/lol/pbu/z4j/SearchService.java b/src/main/java/lol/pbu/z4j/SearchService.java new file mode 100644 index 0000000..126a8dc --- /dev/null +++ b/src/main/java/lol/pbu/z4j/SearchService.java @@ -0,0 +1,46 @@ +package lol.pbu.z4j; + +import jakarta.inject.Inject; +import lol.pbu.z4j.client.SearchClient; +import lol.pbu.z4j.model.ExportResponse; +import lol.pbu.z4j.model.Ticket; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Service for searching and exporting tickets, orgs, users, and groups. + * + * @author Jonathan-Zollinger + * @since 0.1.3 + */ +@Slf4j +public class SearchService { + @Inject + SearchClient searchClient; + + public Flux getTickets(String query) { + return getTickets(query, 100); + } + + public Flux getTickets(String query, int pageSize) { + String ticketPreface = "type:ticket"; + query = query.replace(ticketPreface + " ", "").replace(ticketPreface, ""); + if (query.contains("type:")) { + if (query.contains("_type:")) { + log.debug("verified that the query object does not already contain 'type:ticket': {}", query); + } else { + throw new IllegalArgumentException("Do not include 'type:' in your query. 'type:ticket' will automatically be added to your query."); + } + } + final String finalQuery = ticketPreface + " " + query; + + return searchClient.exportTicket(finalQuery, pageSize, null).expand(page -> { + if (null != page.getMeta() && page.getMeta().getHasMore() && null != page.getMeta().getAfterCursor()) { + log.debug("sending query for {} with cursor {}", finalQuery, page.getMeta().getAfterCursor().substring(0, Math.min(page.getMeta().getAfterCursor().length(), 6))); + return searchClient.exportTicket(finalQuery, pageSize, page.getMeta().getAfterCursor()); + } + return Mono.empty(); + }).flatMapIterable(ExportResponse::getResults).cast(Ticket.class); + } +} diff --git a/src/main/java/lol/pbu/z4j/client/SearchClient.java b/src/main/java/lol/pbu/z4j/client/SearchClient.java index 4bbb1c4..3b8f46f 100644 --- a/src/main/java/lol/pbu/z4j/client/SearchClient.java +++ b/src/main/java/lol/pbu/z4j/client/SearchClient.java @@ -27,10 +27,11 @@ import reactor.core.publisher.Mono; /** - *

{@summary Perform Searches in Zendesk.}

+ *

{@summary Zendesk Search API}

+ *

Reactive client for performing searches in Zendesk. These methods provide low-level access to the search endpoints.

*
    - *
  • Show Search Result Counts with{@link #count}
  • - *
  • Export Search Results with{@link #export}
  • + *
  • Show Search Result Counts with {@link #count(String)}
  • + *
  • Export Search Results with {@link #exportTicket(String, Integer, String)}
  • *
  • List Search Results with{@link #list}
  • *
* @@ -54,7 +55,7 @@ public interface SearchClient { /** *

{@summary Export Search Results}

- *

Exports a set of results. See Exports a set of Tickets. See Query * syntax for the syntax of the {@code query} parameter.

Use this endpoint for search queries that will * return more than 1000 results. The result set is ordered only by the {@code created_at} attribute.

The search @@ -86,18 +87,14 @@ public interface SearchClient { * @param query Returns the search results. See Query syntax for details on the {@code query} parameter. For details on the query syntax, see the Zendesk Support search reference. (required) * @param pageSize The number of results shown in a page. (required) * @param pageAfter The cursor token for fetching the next page of results. (required) - * @param filterType The object type returned by the export query. Can be `ticket`, `organization`, `user`, or `group`. (required) - * @param include Sideloads to include in the response. Accepts a comma-separated list of values. The available sideloads depend on the search result types. (optional) * @return Success response (status code 200) * or Error response (status code 400) */ - @Get("/api/v2/search/export") - Mono<@Valid ExportResponse> export( + @Get("/api/v2/search/export?filter[type]=ticket") + Mono<@Valid ExportResponse> exportTicket( @QueryValue("query") @NotNull String query, @QueryValue("page[size]") @Nullable @Max(1000) Integer pageSize, - @QueryValue("page[after]") @Nullable String pageAfter, - @QueryValue("filter[type]") @NotNull SearchExportType filterType, - @QueryValue("include") @Nullable String include + @QueryValue("page[after]") @Nullable String pageAfter ); /** @@ -113,7 +110,6 @@ public interface SearchClient { * @param query Returns the search results. See Query syntax for details on the {@code query} parameter. For details on the query syntax, see the Zendesk Support search reference. (required) * @param sortBy One of {@code updated_at}, {@code created_at}, {@code priority}, {@code status}, or {@code ticket_type}. Defaults to sorting by relevance (optional) * @param sortOrder Defaults to descending (optional) - * @param include Sideloads to include in the response. Accepts a comma-separated list of values. The available sideloads depend on the search result types. (optional) * @param page The page number to retrieve. (optional) * @param perPage The count of results to include in each page. (optional) * @return Success response (status code 200) @@ -124,7 +120,6 @@ public interface SearchClient { @QueryValue("query") @NotNull String query, @QueryValue("sort_by") @Nullable SortBy sortBy, @QueryValue("sort_order") @Nullable SortOrder sortOrder, - @QueryValue("include") @Nullable String include, @QueryValue("page") @Nullable Integer page, @QueryValue("per_page") @Nullable @Max(100) Integer perPage ); diff --git a/src/main/java/lol/pbu/z4j/model/SearchExportType.java b/src/main/java/lol/pbu/z4j/model/SearchExportType.java index 6f2e511..4a554a9 100644 --- a/src/main/java/lol/pbu/z4j/model/SearchExportType.java +++ b/src/main/java/lol/pbu/z4j/model/SearchExportType.java @@ -28,7 +28,7 @@ import java.util.stream.Collectors; /** - *

{@summary The object type returned by the export query.

+ *

{@summary The object type returned by the exportTicket query.

* Can be ticket, organization, user, or group. * * @author Jonathan-Zollinger diff --git a/src/main/java/lol/pbu/z4j/model/Ticket.java b/src/main/java/lol/pbu/z4j/model/Ticket.java index a339867..591d21b 100644 --- a/src/main/java/lol/pbu/z4j/model/Ticket.java +++ b/src/main/java/lol/pbu/z4j/model/Ticket.java @@ -513,7 +513,7 @@ public class Ticket implements Exportable { private TicketType type; /** - * When this record last got updated. It is updated only if the update generates a ticket event + * When this record last got updated. It is updated only if the update generates a ticket event */ @Nullable @JsonProperty(JSON_PROPERTY_UPDATED_AT) diff --git a/src/main/resources/Ticketing.yaml b/src/main/resources/Ticketing.yaml index bf23939..9b409de 100644 --- a/src/main/resources/Ticketing.yaml +++ b/src/main/resources/Ticketing.yaml @@ -5969,7 +5969,7 @@ paths: maximum: 1000 - name: filter[exclude_deleted] in: query - description: If true, exclude deleted records from the export + description: If true, exclude deleted records from the exportTicket schema: type: boolean default: false @@ -5979,7 +5979,7 @@ paths: - Custom Object Records - Incremental Export summary: Incremental Custom Object Record Export, Cursor Based - description: "Returns the custom object records that changed since the start time. This endpoint supports \ncursor-based incremental exports for custom object records.\n\nThis endpoint only supports cursor-based pagination and does not support offset-based pagination.\nCursor-based exports provide more consistent performance and response body sizes. For more information, \nsee [Cursor-based incremental exports](/documentation/ticketing/managing-tickets/using-the-incremental-export-api#cursor-based-incremental-exports) in [Using the Incremental Exports API](/documentation/ticketing/managing-tickets/using-the-incremental-export-api).\n\n#### Allowed For\n\n* Admins\n* Agents with custom object read permissions\n\n#### Rate Limiting\n\nYou can make up to 10 requests per minute to this endpoint.\n\n#### Notes\n\n- `start_time` is only required for the initial request for the pages in the record set, then `cursor` is required for all subsequent requests\n- The `start_time` must be more than 60 seconds ago\n- Deleted records will have their field values replaced with \"[DELETED]\" unless excluded via filter\n- Photo fields are excluded from incremental export responses\n" + description: "Returns the custom object records that changed since the start time. This endpoint supports \ncursor-based incremental exports for custom object records.\n\nThis endpoint only supports cursor-based pagination and does not support offset-based pagination.\nCursor-based exports provide more consistent performance and response body sizes. For more information, \nsee [Cursor-based incremental exports](/documentation/ticketing/managing-tickets/using-the-incremental-exportTicket-api#cursor-based-incremental-exports) in [Using the Incremental Exports API](/documentation/ticketing/managing-tickets/using-the-incremental-exportTicket-api).\n\n#### Allowed For\n\n* Admins\n* Agents with custom object read permissions\n\n#### Rate Limiting\n\nYou can make up to 10 requests per minute to this endpoint.\n\n#### Notes\n\n- `start_time` is only required for the initial request for the pages in the record set, then `cursor` is required for all subsequent requests\n- The `start_time` must be more than 60 seconds ago\n- Deleted records will have their field values replaced with \"[DELETED]\" unless excluded via filter\n- Photo fields are excluded from incremental exportTicket responses\n" responses: "200": description: Success response @@ -11371,7 +11371,7 @@ paths: type: string - name: filter[type] in: query - description: The object type returned by the export query. Can be `ticket`, `organization`, `user`, or `group`. + description: The object type returned by the exportTicket query. Can be `ticket`, `organization`, `user`, or `group`. schema: type: string - $ref: '#/components/parameters/SearchInclude' @@ -23952,7 +23952,7 @@ components: description: 'Allowed values: "edit", "full", "none", "readonly"' export_views: type: boolean - description: Whether or not the agent can export views + description: Whether or not the agent can exportTicket views forum_access: type: string description: 'The kind of access the agent has to Guide. Allowed values: "edit-topics", "full", "readonly"' @@ -25301,19 +25301,19 @@ components: readOnly: true filter: type: object - description: Applied filters for the export + description: Applied filters for the exportTicket properties: exclude_deleted: type: boolean - description: Whether deleted records were excluded from the export + description: Whether deleted records were excluded from the exportTicket nullable: true meta: type: object - description: Metadata about the export operation + description: Metadata about the exportTicket operation properties: has_more: type: boolean - description: Indicates whether there are more records to export after this page + description: Indicates whether there are more records to exportTicket after this page example: after_cursor: MTU3NjYxMzUzOS4wfHw0Njd8 after_url: https://company.zendesk.com/api/v2/incremental/custom_objects/my_object/cursor?cursor=MTU3NjYxMzUzOS4wfHw0Njd8 @@ -28270,7 +28270,7 @@ components: readOnly: true meta: type: object - description: Metadata for the export query response. + description: Metadata for the exportTicket query response. properties: after_cursor: type: string @@ -31633,7 +31633,7 @@ components: updated_at: type: string format: date-time - description: When this record last got updated. It is updated only if the update generates a [ticket event](#incremental-ticket-event-export) + description: When this record last got updated. It is updated only if the update generates a [ticket event](#incremental-ticket-event-exportTicket) readOnly: true updated_stamp: type: string @@ -34824,7 +34824,7 @@ components: IncrementalResource: name: incremental_resource in: path - description: The resource requested for incremental sample export + description: The resource requested for incremental sample exportTicket required: true schema: type: string @@ -34848,7 +34848,7 @@ components: IncrementalUnixTime: name: start_time in: query - description: The time to start the incremental export from. Must be at least one minute in the past. Data isn't provided for the most recent minute + description: The time to start the incremental exportTicket from. Must be at least one minute in the past. Data isn't provided for the most recent minute required: true schema: type: integer @@ -35111,7 +35111,7 @@ components: OptionalIncrementalUnixTime: name: start_time in: query - description: The time to start the incremental export from. Must be at least one minute in the past. Data isn't provided for the most recent minute. Required on the initial export request; not required on subsequent cursor-based pagination requests + description: The time to start the incremental exportTicket from. Must be at least one minute in the past. Data isn't provided for the most recent minute. Required on the initial exportTicket request; not required on subsequent cursor-based pagination requests schema: type: integer example: 1332034771 @@ -38940,7 +38940,7 @@ components: is_valid: false reason: google.com IncrementalCustomObjectRecordsResponseExample: - summary: Successful incremental export of custom object records + summary: Successful incremental exportTicket of custom object records description: Example response showing custom object records that have changed since the start time value: after_cursor: MTU3NjYxMzUzOS4wfHw0Njd8 diff --git a/src/test/groovy/lol/pbu/z4j/SearchServiceSpec.groovy b/src/test/groovy/lol/pbu/z4j/SearchServiceSpec.groovy new file mode 100644 index 0000000..eefbe78 --- /dev/null +++ b/src/test/groovy/lol/pbu/z4j/SearchServiceSpec.groovy @@ -0,0 +1,78 @@ +package lol.pbu.z4j + +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.exceptions.HttpClientResponseException +import lol.pbu.z4j.model.Ticket +import spock.lang.Shared + +class SearchServiceSpec extends Z4jSpec { + + @Shared + SearchService adminSearchService, agentSearchService, userSearchService + + def setupSpec() { + adminSearchService = adminCtx.getBean(SearchService.class) + agentSearchService = agentCtx.getBean(SearchService.class) + userSearchService = userCtx.getBean(SearchService.class) + } + + def "Can query tickets as #agentType using getTickets(query, pageSize)"(SearchService service, String agentType) { + when: "The service queries for tickets" + List tickets = service.getTickets("", 1000).collectList().block() + + then: "The result contains tickets" + tickets.size() > 0 + + and: "No exception is thrown" + noExceptionThrown() + + where: + [service, agentType] << [[adminSearchService, "admin"], [agentSearchService, "agent"]] + } + + def "User cannot query tickets and receives forbidden"() { + when: "A standard user attempts to query tickets" + userSearchService.getTickets("").collectList().block() + + then: "Expect 403 Error" + def error = thrown(HttpClientResponseException) + error.getStatus() == HttpStatus.FORBIDDEN + } + + def "Can query tickets as #agentType using getTickets(query)"(SearchService service, String agentType) { + when: "The service queries for tickets" + Ticket ticket = service.getTickets("").blockFirst() + + then: "The result contains tickets" + null != ticket + + and: "No exception is thrown" + noExceptionThrown() + + where: + [service, agentType] << [[adminSearchService, "admin"], [agentSearchService, "agent"]] + } + + def "should NOT throw exception when 'type:ticket' is included in the query"() { + expect: + agentSearchService.getTickets("type:ticket", 2) + + where: + query << ["type:ticket", "type:ticket ticket_type:problem", "ticket_type:problem"] + } + + def "Validation correctly identifies illegal type filters"(String query) { + when: + agentSearchService.getTickets(query, 2) + + then: + thrown(IllegalArgumentException) + + where: + query | _ + "type:user" | _ + "type:organization" | _ + "status:open type:group" | _ + "type:ticket type:user" | _ + } +} diff --git a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy index dbf46d4..b7ebb34 100644 --- a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy +++ b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy @@ -34,19 +34,18 @@ class SearchClientSpec extends Z4jSpec { /* ---------- list() tests --------------- */ @SuppressWarnings("GroovyAssignabilityCheck") - @Unroll("an #clientName user can run the list method with sortby: #sortBy, sortOrder: #sortOrder and include: #include") - void "can run the list method"(String clientName, SearchClient client, SortBy sortBy, SortOrder sortOrder, String include) { + @Unroll("an #clientName user can run the list method with sortby: #sortBy, sortOrder: #sortOrder") + void "can run the list method"(String clientName, SearchClient client, SortBy sortBy, SortOrder sortOrder) { when: - client.list("type:group", sortBy, sortOrder, include, null, null).block() + client.list("type:group", sortBy, sortOrder, null, null).block() then: noExceptionThrown() where: - [[client, clientName], sortBy, sortOrder, include] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]], - [SortBy.values(), null].flatten(), - [SortOrder.values(), null].flatten(), - [null, faker.cat().name()]].combinations() + [[client, clientName], sortBy, sortOrder] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]], + [SortBy.values(), null].flatten(), + [SortOrder.values(), null].flatten()].combinations() } void "can query the list method when results include more than the page size"() { @@ -60,10 +59,10 @@ class SearchClientSpec extends Z4jSpec { when: def page = 1 List responses = [] - SearchResponse response = adminSearchClient.list(ticketQuery, null, null, null, page, 2).block() + SearchResponse response = adminSearchClient.list(ticketQuery, null, null, page, 2).block() responses << response while (response.nextPage != null && page < 3) { - response = adminSearchClient.list(ticketQuery, null, null, null, page, 2).block() + response = adminSearchClient.list(ticketQuery, null, null, page, 2).block() page++ responses << response } @@ -73,18 +72,18 @@ class SearchClientSpec extends Z4jSpec { } @Unroll("a simple user querying the list method fails with #sortBy, #sortOrder and #include") - void "cannot run searchClient.list()"(SearchClient client, SortBy sortBy, SortOrder sortOrder, String include) { + void "cannot run searchClient.list()"(SearchClient client, SortBy sortBy, SortOrder sortOrder) { when: - client.list(faker.bluey().quote(), sortBy, sortOrder, include, null, null).block() + client.list(faker.bluey().quote(), sortBy, sortOrder, null, null).block() then: thrown(HttpClientResponseException) where: - [client, sortBy, sortOrder, include] << [[userSearchClient], - [SortBy.values(), null].flatten(), - [SortOrder.values(), null].flatten(), - [null, faker.cat().name()]].combinations() + [client, sortBy, sortOrder] << [[userSearchClient], + [SortBy.values(), null].flatten(), + [SortOrder.values(), null].flatten(), + [null, faker.cat().name()]].combinations() } /* ---------- count() tests --------------- */ @@ -113,29 +112,30 @@ class SearchClientSpec extends Z4jSpec { userSearchClient | _ } - /* ---------- export() tests --------------- */ + /* ---------- exportTicket() tests --------------- */ @SuppressWarnings("GroovyAssignabilityCheck") - void "an #clientName can call export method with pageSize: #pageSize, filterType: #filterType and include: #include"( - String clientName, SearchClient client, int pageSize, SearchExportType filterType, String include) { + void "an #clientName can call export method and paginate properly"(String clientName, SearchClient client) { given: - client.count("type: $filterType").block() + String query = "type:ticket" + def count = client.count(query).block() + int pageSize = Math.max(2, Math.min(1000, (count.getCount() / 2).intValue() + 1)) when: - ExportResponse response = client.export("type: $filterType", pageSize, null, filterType, include).block() + ExportResponse response = client.exportTicket(query, pageSize, null).block() + List tickets = response.getResults() and: - if (response.getLinks().getNext() != null) { - client.export("type: $filterType", pageSize, response.getMeta().getAfterCursor(), filterType, include).block() + while (response.getMeta().getAfterCursor() != null && response.getMeta().getHasMore()) { + response = client.exportTicket(query, pageSize, response.getMeta().getAfterCursor()).block() + tickets.addAll(response.getResults()) } then: noExceptionThrown() + tickets.size() == count.getCount() where: - [[client, clientName], pageSize, filterType, include] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]], - [100], - SearchExportType.values(), - ["organizations"]].combinations() + [[client, clientName]] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]]].combinations() } } From 7d5982fa9134d885adda85e7c5201509de09fa1a Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Fri, 8 May 2026 16:39:48 -0600 Subject: [PATCH 8/9] docs: add docs to SearchService Signed-off-by: jonathan zollinger --- src/main/java/lol/pbu/z4j/SearchService.java | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/main/java/lol/pbu/z4j/SearchService.java b/src/main/java/lol/pbu/z4j/SearchService.java index 126a8dc..894c96e 100644 --- a/src/main/java/lol/pbu/z4j/SearchService.java +++ b/src/main/java/lol/pbu/z4j/SearchService.java @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Peanut Butter Unicorn, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package lol.pbu.z4j; import jakarta.inject.Inject; @@ -19,10 +34,25 @@ public class SearchService { @Inject SearchClient searchClient; + /** + * Searches for tickets using the provided query string with a default page size of 100. + * + * @param query The search query string. Note: 'type:ticket' is automatically prepended. + * @return A {@link Flux} emitting the {@link Ticket} results found. + * @see #getTickets(String, int) + */ public Flux getTickets(String query) { return getTickets(query, 100); } + + /** + * Searches for tickets using the provided query string and page size. + * + * @param query The search query string. Note: 'type:ticket' is automatically prepended. + * @param pageSize The number of results to return per page. + * @return A {@link Flux} emitting the {@link Ticket} results found. + */ public Flux getTickets(String query, int pageSize) { String ticketPreface = "type:ticket"; query = query.replace(ticketPreface + " ", "").replace(ticketPreface, ""); From d9e661b5f6e2b86de395a0ddf8a254f6c3e33cc5 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Fri, 8 May 2026 17:16:48 -0600 Subject: [PATCH 9/9] test: correct unroll and make count more forgiving Signed-off-by: jonathan zollinger --- src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy index b7ebb34..b912a75 100644 --- a/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy +++ b/src/test/groovy/lol/pbu/z4j/client/SearchClientSpec.groovy @@ -71,7 +71,7 @@ class SearchClientSpec extends Z4jSpec { noExceptionThrown() } - @Unroll("a simple user querying the list method fails with #sortBy, #sortOrder and #include") + @Unroll("a simple user querying the list method fails with #sortBy and #sortOrder") void "cannot run searchClient.list()"(SearchClient client, SortBy sortBy, SortOrder sortOrder) { when: client.list(faker.bluey().quote(), sortBy, sortOrder, null, null).block() @@ -133,7 +133,9 @@ class SearchClientSpec extends Z4jSpec { then: noExceptionThrown() - tickets.size() == count.getCount() + tickets.size() > (count.getCount() * 0.95) + tickets.size() < (count.getCount() * 1.05) + where: [[client, clientName]] << [[[adminSearchClient, "admin"], [agentSearchClient, "agent"]]].combinations()