From 85e57516ab0f9eb6106ca6cf6786246e65f34421 Mon Sep 17 00:00:00 2001 From: Johannes Graf Date: Mon, 2 Feb 2026 21:49:31 +0100 Subject: [PATCH 1/2] add wiremock based integration tests --- pom.xml | 6 + .../java/sdk/LexofficeApiBuilder.java | 11 + .../lexoffice/java/sdk/RequestContext.java | 2 +- .../java/sdk/model/ExecutionInterval.java | 2 + .../chain/ContactChainIntegrationTest.java | 436 ++++++++++++++++++ ...EventSubscriptionChainIntegrationTest.java | 173 +++++++ .../chain/InvoiceChainIntegrationTest.java | 212 +++++++++ .../chain/QuotationChainIntegrationTest.java | 202 ++++++++ ...RecurringTemplateChainIntegrationTest.java | 282 +++++++++++ .../VoucherListChainIntegrationTest.java | 263 +++++++++++ 10 files changed, 1588 insertions(+), 1 deletion(-) create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/chain/EventSubscriptionChainIntegrationTest.java create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/chain/InvoiceChainIntegrationTest.java create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/chain/QuotationChainIntegrationTest.java create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/chain/RecurringTemplateChainIntegrationTest.java create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChainIntegrationTest.java diff --git a/pom.xml b/pom.xml index fbe5afa..8b6200e 100644 --- a/pom.xml +++ b/pom.xml @@ -106,6 +106,12 @@ spring-boot-starter-test test + + org.wiremock + wiremock-standalone + 3.13.2 + test + diff --git a/src/main/java/de/focus_shift/lexoffice/java/sdk/LexofficeApiBuilder.java b/src/main/java/de/focus_shift/lexoffice/java/sdk/LexofficeApiBuilder.java index dbf3f16..b27a874 100644 --- a/src/main/java/de/focus_shift/lexoffice/java/sdk/LexofficeApiBuilder.java +++ b/src/main/java/de/focus_shift/lexoffice/java/sdk/LexofficeApiBuilder.java @@ -1,5 +1,6 @@ package de.focus_shift.lexoffice.java.sdk; +import com.google.common.base.Preconditions; import lombok.AccessLevel; import lombok.Getter; @@ -8,6 +9,7 @@ public class LexofficeApiBuilder { public static final String LEXOFFICE_API = "api.lexware.io/v1"; + private String protocol = "https"; private String host = LEXOFFICE_API; private String apiToken = null; private ThrottleProvider throttleProvider; @@ -18,6 +20,15 @@ public LexofficeApiBuilder apiToken(String apiToken) { } + public LexofficeApiBuilder protocol(String protocol) { + Preconditions.checkArgument( + "http".equals(protocol) || "https".equals(protocol), + "Protocol must be 'http' or 'https'" + ); + this.protocol = protocol; + return this; + } + public LexofficeApiBuilder host(String host) { this.host = host; return this; diff --git a/src/main/java/de/focus_shift/lexoffice/java/sdk/RequestContext.java b/src/main/java/de/focus_shift/lexoffice/java/sdk/RequestContext.java index 17f748c..962cf7a 100644 --- a/src/main/java/de/focus_shift/lexoffice/java/sdk/RequestContext.java +++ b/src/main/java/de/focus_shift/lexoffice/java/sdk/RequestContext.java @@ -43,7 +43,7 @@ protected JsonMapper getJsonMapper() { public RestUriBuilder getUriBuilder() { return new RestUriBuilder() - .protocol("https") + .protocol(apiBuilder.getProtocol()) .host(apiBuilder.getHost()); } diff --git a/src/main/java/de/focus_shift/lexoffice/java/sdk/model/ExecutionInterval.java b/src/main/java/de/focus_shift/lexoffice/java/sdk/model/ExecutionInterval.java index a406c01..adf362a 100644 --- a/src/main/java/de/focus_shift/lexoffice/java/sdk/model/ExecutionInterval.java +++ b/src/main/java/de/focus_shift/lexoffice/java/sdk/model/ExecutionInterval.java @@ -6,8 +6,10 @@ public enum ExecutionInterval { WEEKLY("WEEKLY"), + BIWEEKLY("BIWEEKLY"), MONTHLY("MONTHLY"), QUARTERLY("QUARTERLY"), + BIANNUALLY("BIANNUALLY"), ANNUALLY("ANNUALLY"); @Getter diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java new file mode 100644 index 0000000..052d0d3 --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java @@ -0,0 +1,436 @@ +package de.focus_shift.lexoffice.java.sdk.chain; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import de.focus_shift.lexoffice.java.sdk.LexofficeApi; +import de.focus_shift.lexoffice.java.sdk.LexofficeApiBuilder; +import de.focus_shift.lexoffice.java.sdk.model.Company; +import de.focus_shift.lexoffice.java.sdk.model.Contact; +import de.focus_shift.lexoffice.java.sdk.model.ItemCreatedResult; +import de.focus_shift.lexoffice.java.sdk.model.Page; +import de.focus_shift.lexoffice.java.sdk.model.Roles; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@WireMockTest +class ContactChainIntegrationTest { + + private LexofficeApi lexofficeApi; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + String wiremockHost = wmRuntimeInfo.getHttpBaseUrl().replace("http://", "") + "/v1"; + + lexofficeApi = + new LexofficeApiBuilder().apiToken("test-api-key").protocol("http").host(wiremockHost).build(); + } + + @Test + void getContact() { + String contactId = "be9475a4-ef80-442b-95eb-e56a7b2d5596"; + + stubFor( + get(urlPathEqualTo("/v1/contacts/" + contactId)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "be9475a4-ef80-442b-95eb-e56a7b2d5596", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "version": 1, + "roles": { + "customer": { + "number": 10001 + } + }, + "company": { + "name": "Berliner Kindl GmbH", + "taxNumber": "12345/67890", + "vatRegistrationId": "DE123456789", + "allowTaxFreeInvoices": false, + "contactPersons": [ + { + "salutation": "Herr", + "firstName": "Max", + "lastName": "Mustermann", + "primary": true, + "emailAddress": "max.mustermann@example.com", + "phoneNumber": "+49 30 123456" + } + ] + }, + "addresses": { + "billing": [ + { + "supplement": "Hinterhaus", + "street": "Juliusstraße 25", + "zip": "12051", + "city": "Berlin", + "countryCode": "DE" + } + ] + }, + "emailAddresses": { + "business": ["info@example.com"], + "office": ["office@example.com"] + }, + "phoneNumbers": { + "business": ["+49 30 123456"] + }, + "archived": false + } + """))); + + Contact contact = lexofficeApi.contact().get(contactId); + + assertThat(contact).isNotNull(); + assertThat(contact.getId()).isEqualTo(contactId); + assertThat(contact.getCompany().getName()).isEqualTo("Berliner Kindl GmbH"); + assertThat(contact.getCompany().getVatRegistrationId()).isEqualTo("DE123456789"); + assertThat(contact.getRoles().getCustomer()).isNotNull(); + assertThat(contact.getRoles().getCustomer().getNumber()).isEqualTo(10001L); + } + + @Test + void fetchContacts() { + stubFor( + get(urlPathEqualTo("/v1/contacts")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "be9475a4-ef80-442b-95eb-e56a7b2d5596", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "version": 1, + "roles": { + "customer": { "number": 10001 } + }, + "company": { + "name": "Berliner Kindl GmbH" + }, + "archived": false + }, + { + "id": "c1234567-ef80-442b-95eb-e56a7b2d5596", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "version": 2, + "roles": { + "customer": { "number": 10002 } + }, + "company": { + "name": "Test GmbH" + }, + "archived": false + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 2, + "numberOfElements": 2, + "size": 25, + "number": 0 + } + """))); + + Page contactPage = lexofficeApi.contact().fetch().get(); + + assertThat(contactPage).isNotNull(); + assertThat(contactPage.getContent()).hasSize(2); + assertThat(contactPage.getTotalElements()).isEqualTo(2); + assertThat(contactPage.getContent().get(0).getCompany().getName()).isEqualTo("Berliner Kindl GmbH"); + assertThat(contactPage.getContent().get(1).getCompany().getName()).isEqualTo("Test GmbH"); + } + + @Test + void fetchContactsFilteredByCustomer() { + stubFor( + get(urlPathEqualTo("/v1/contacts")) + .withQueryParam("customer", equalTo("true")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "be9475a4-ef80-442b-95eb-e56a7b2d5596", + "version": 1, + "roles": { + "customer": { "number": 10001 } + }, + "company": { + "name": "Berliner Kindl GmbH" + }, + "archived": false + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 1, + "numberOfElements": 1, + "size": 25, + "number": 0 + } + """))); + + Page contactPage = lexofficeApi.contact().fetch().customer(true).get(); + + assertThat(contactPage).isNotNull(); + assertThat(contactPage.getContent()).hasSize(1); + assertThat(contactPage.getContent().get(0).getRoles().getCustomer()).isNotNull(); + } + + @Test + void fetchContactsFilteredByName() { + stubFor( + get(urlPathEqualTo("/v1/contacts")) + .withQueryParam("name", equalTo("Berliner")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "be9475a4-ef80-442b-95eb-e56a7b2d5596", + "version": 1, + "roles": { + "customer": { "number": 10001 } + }, + "company": { + "name": "Berliner Kindl GmbH" + }, + "archived": false + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 1, + "numberOfElements": 1, + "size": 25, + "number": 0 + } + """))); + + Page contactPage = lexofficeApi.contact().fetch().name("Berliner").get(); + + assertThat(contactPage).isNotNull(); + assertThat(contactPage.getContent()).hasSize(1); + assertThat(contactPage.getContent().get(0).getCompany().getName()).contains("Berliner"); + } + + @Test + void fetchContactsFilteredByNameWithSpecialCharacters() { + String companyName = "Müller & Söhne GmbH"; + + stubFor( + get(urlPathEqualTo("/v1/contacts")) + .withQueryParam("name", equalTo(companyName)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "special-char-uuid", + "version": 1, + "roles": { + "customer": { "number": 10003 } + }, + "company": { + "name": "Müller & Söhne GmbH" + }, + "archived": false + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 1, + "numberOfElements": 1, + "size": 25, + "number": 0 + } + """))); + + Page contactPage = lexofficeApi.contact().fetch().name(companyName).get(); + + assertThat(contactPage).isNotNull(); + assertThat(contactPage.getContent()).hasSize(1); + assertThat(contactPage.getContent().get(0).getCompany().getName()).isEqualTo(companyName); + } + + @Test + void fetchContactsWithPagination() { + stubFor( + get(urlPathEqualTo("/v1/contacts")) + .withQueryParam("page", equalTo("0")) + .withQueryParam("size", equalTo("10")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "be9475a4-ef80-442b-95eb-e56a7b2d5596", + "version": 1, + "company": { + "name": "Berliner Kindl GmbH" + }, + "archived": false + } + ], + "first": true, + "last": false, + "totalPages": 5, + "totalElements": 50, + "numberOfElements": 10, + "size": 10, + "number": 0 + } + """))); + + Page contactPage = lexofficeApi.contact().fetch().page(0).pageSize(10).get(); + + assertThat(contactPage).isNotNull(); + assertThat(contactPage.getTotalPages()).isEqualTo(5); + assertThat(contactPage.getTotalElements()).isEqualTo(50); + assertThat(contactPage.getSize()).isEqualTo(10); + assertThat(contactPage.getFirst()).isTrue(); + assertThat(contactPage.getLast()).isFalse(); + } + + @Test + void createContact() { + stubFor( + post(urlPathEqualTo("/v1/contacts")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "e1234567-ef80-442b-95eb-e56a7b2d5596", + "resourceUri": "https://api.lexoffice.io/v1/contacts/e1234567-ef80-442b-95eb-e56a7b2d5596", + "createdDate": "2023-06-20T10:30:00.000+02:00", + "updatedDate": "2023-06-20T10:30:00.000+02:00", + "version": 0 + } + """))); + + Contact newContact = + Contact.builder() + .version(0L) + .company(Company.builder().name("Neue Firma GmbH").build()) + .roles(Roles.builder().build()) + .build(); + + ItemCreatedResult result = lexofficeApi.contact().create(newContact); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo("e1234567-ef80-442b-95eb-e56a7b2d5596"); + assertThat(result.getResourceUri()).contains("/v1/contacts/"); + assertThat(result.getVersion()).isEqualTo(0L); + + verify( + postRequestedFor(urlPathEqualTo("/v1/contacts")) + .withRequestBody( + equalToJson( + """ + { + "version": 0, + "company": { + "name": "Neue Firma GmbH" + }, + "roles": {} + } + """, + true, + true))); + } + + @Test + void updateContact() { + String contactId = "be9475a4-ef80-442b-95eb-e56a7b2d5596"; + + stubFor( + put(urlPathEqualTo("/v1/contacts/" + contactId)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "be9475a4-ef80-442b-95eb-e56a7b2d5596", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "version": 2, + "roles": { + "customer": { "number": 10001 } + }, + "company": { + "name": "Berliner Kindl GmbH - Updated" + }, + "archived": false + } + """))); + + Contact contactToUpdate = + Contact.builder() + .id(contactId) + .version(1L) + .company(Company.builder().name("Berliner Kindl GmbH - Updated").build()) + .build(); + + Contact updatedContact = lexofficeApi.contact().update(contactToUpdate); + + assertThat(updatedContact).isNotNull(); + assertThat(updatedContact.getId()).isEqualTo(contactId); + assertThat(updatedContact.getVersion()).isEqualTo(2L); + assertThat(updatedContact.getCompany().getName()).isEqualTo("Berliner Kindl GmbH - Updated"); + + verify( + putRequestedFor(urlPathEqualTo("/v1/contacts/" + contactId)) + .withRequestBody( + equalToJson( + """ + { + "id": "be9475a4-ef80-442b-95eb-e56a7b2d5596", + "version": 1, + "company": { + "name": "Berliner Kindl GmbH - Updated" + } + } + """, + true, + true))); + } +} diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/EventSubscriptionChainIntegrationTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/EventSubscriptionChainIntegrationTest.java new file mode 100644 index 0000000..1b69677 --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/EventSubscriptionChainIntegrationTest.java @@ -0,0 +1,173 @@ +package de.focus_shift.lexoffice.java.sdk.chain; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import de.focus_shift.lexoffice.java.sdk.LexofficeApi; +import de.focus_shift.lexoffice.java.sdk.LexofficeApiBuilder; +import de.focus_shift.lexoffice.java.sdk.model.EventSubscription; +import de.focus_shift.lexoffice.java.sdk.model.EventType; +import de.focus_shift.lexoffice.java.sdk.model.ItemCreatedResult; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@WireMockTest +class EventSubscriptionChainIntegrationTest { + + private LexofficeApi lexofficeApi; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + String wiremockHost = wmRuntimeInfo.getHttpBaseUrl().replace("http://", "") + "/v1"; + + lexofficeApi = + new LexofficeApiBuilder().apiToken("test-api-key").protocol("http").host(wiremockHost).build(); + } + + @Test + void getEventSubscription() { + String subscriptionId = "6a5c555e-060c-4fde-9ac3-4c7a396e5e1e"; + + stubFor( + get(urlPathEqualTo("/v1/event-subscriptions/" + subscriptionId)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "subscriptionId": "6a5c555e-060c-4fde-9ac3-4c7a396e5e1e", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-06-10T12:30:45.123+02:00", + "eventType": "invoice.created", + "callbackUrl": "https://example.com/webhook/invoice" + } + """))); + + EventSubscription subscription = lexofficeApi.eventSubscriptions().get(subscriptionId); + + assertThat(subscription).isNotNull(); + assertThat(subscription.getSubscriptionId()).isEqualTo(subscriptionId); + assertThat(subscription.getEventType()).isEqualTo(EventType.INVOICE_CREATED); + assertThat(subscription.getCallbackUrl()).isEqualTo("https://example.com/webhook/invoice"); + } + + @Test + void getAllEventSubscriptions() { + stubFor( + get(urlPathEqualTo("/v1/event-subscriptions")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "subscriptionId": "6a5c555e-060c-4fde-9ac3-4c7a396e5e1e", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-06-10T12:30:45.123+02:00", + "eventType": "invoice.created", + "callbackUrl": "https://example.com/webhook/invoice" + }, + { + "subscriptionId": "7b6d666f-171d-5gef-0bd4-5d8b407f6f2f", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-06-11T09:15:30.456+02:00", + "eventType": "contact.created", + "callbackUrl": "https://example.com/webhook/contact" + }, + { + "subscriptionId": "8c7e777g-282e-6hfg-1ce5-6e9c518g7g3g", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-06-12T14:45:00.789+02:00", + "eventType": "quotation.status.changed", + "callbackUrl": "https://example.com/webhook/quotation" + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 3, + "numberOfElements": 3, + "size": 25, + "number": 0 + } + """))); + + List subscriptions = lexofficeApi.eventSubscriptions().getAll(); + + assertThat(subscriptions).hasSize(3); + assertThat(subscriptions.get(0).getEventType()).isEqualTo(EventType.INVOICE_CREATED); + assertThat(subscriptions.get(1).getEventType()).isEqualTo(EventType.CONTACT_CREATED); + assertThat(subscriptions.get(2).getEventType()).isEqualTo(EventType.QUOTATION_STATUS_CHANGED); + } + + @Test + void createEventSubscription() { + stubFor( + post(urlPathEqualTo("/v1/event-subscriptions")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "6a5c555e-060c-4fde-9ac3-4c7a396e5e1e", + "resourceUri": "https://api.lexoffice.io/v1/event-subscriptions/6a5c555e-060c-4fde-9ac3-4c7a396e5e1e", + "createdDate": "2023-06-10T12:30:45.123+02:00", + "updatedDate": "2023-06-10T12:30:45.123+02:00", + "version": 0 + } + """))); + + EventSubscription newSubscription = + EventSubscription.builder() + .eventType(EventType.INVOICE_CREATED) + .callbackUrl("https://example.com/webhook/invoice") + .build(); + + ItemCreatedResult result = lexofficeApi.eventSubscriptions().create(newSubscription); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo("6a5c555e-060c-4fde-9ac3-4c7a396e5e1e"); + assertThat(result.getResourceUri()).contains("/v1/event-subscriptions/"); + assertThat(result.getVersion()).isEqualTo(0L); + + verify( + postRequestedFor(urlPathEqualTo("/v1/event-subscriptions")) + .withRequestBody( + equalToJson( + """ + { + "eventType": "invoice.created", + "callbackUrl": "https://example.com/webhook/invoice" + } + """, + true, + true))); + } + + @Test + void deleteEventSubscription() { + String subscriptionId = "6a5c555e-060c-4fde-9ac3-4c7a396e5e1e"; + + stubFor( + delete(urlPathEqualTo("/v1/event-subscriptions/" + subscriptionId)) + .willReturn(aResponse().withStatus(204))); + + lexofficeApi.eventSubscriptions().delete(subscriptionId); + } +} diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/InvoiceChainIntegrationTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/InvoiceChainIntegrationTest.java new file mode 100644 index 0000000..ebc1846 --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/InvoiceChainIntegrationTest.java @@ -0,0 +1,212 @@ +package de.focus_shift.lexoffice.java.sdk.chain; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import de.focus_shift.lexoffice.java.sdk.LexofficeApi; +import de.focus_shift.lexoffice.java.sdk.LexofficeApiBuilder; +import de.focus_shift.lexoffice.java.sdk.model.Invoice; +import de.focus_shift.lexoffice.java.sdk.model.VoucherStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@WireMockTest +class InvoiceChainIntegrationTest { + + private LexofficeApi lexofficeApi; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + String wiremockHost = wmRuntimeInfo.getHttpBaseUrl().replace("http://", "") + "/v1"; + + lexofficeApi = + new LexofficeApiBuilder().apiToken("test-api-key").protocol("http").host(wiremockHost).build(); + } + + @Test + void getInvoice() { + String invoiceId = "e9066f04-8cc7-4616-93f8-ac9571ec5e11"; + + stubFor( + get(urlPathEqualTo("/v1/invoices/" + invoiceId)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "e9066f04-8cc7-4616-93f8-ac9571ec5e11", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-06-17T18:32:07.480+02:00", + "updatedDate": "2023-06-17T18:32:07.551+02:00", + "version": 1, + "language": "de", + "archived": false, + "voucherStatus": "open", + "voucherNumber": "RE1019", + "voucherDate": "2023-02-22T00:00:00.000+01:00", + "dueDate": "2023-03-08T00:00:00.000+01:00", + "address": { + "contactId": "97c5794f-8ab2-43ad-b459-c5980b055e4d", + "name": "Berliner Kindl GmbH", + "street": "Juliusstraße 25", + "zip": "12051", + "city": "Berlin", + "countryCode": "DE" + }, + "lineItems": [ + { + "id": "97b98491-e953-4dc9-97a9-ae437a8052b4", + "type": "custom", + "name": "Axa Rahmenschloss Defender RL", + "description": "Vollständig symmetrisches Design in metallicfarbener Ausführung. Der ergonomische Bedienkopf mit praktischem Zahlenfeld.", + "quantity": 1.0000, + "unitName": "Stück", + "unitPrice": { + "currency": "EUR", + "netAmount": 20.08, + "grossAmount": 23.90, + "taxRatePercentage": 19 + }, + "lineItemAmount": 23.90 + } + ], + "totalPrice": { + "currency": "EUR", + "totalNetAmount": 20.08, + "totalGrossAmount": 23.90, + "totalTaxAmount": 3.82 + }, + "taxAmounts": [ + { + "taxRatePercentage": 19, + "taxAmount": 3.82, + "netAmount": 20.08 + } + ], + "taxConditions": { + "taxType": "gross" + }, + "paymentConditions": { + "paymentTermLabel": "10 Tage - 3 %, 30 Tage netto", + "paymentTermDuration": 30, + "paymentDiscountConditions": { + "discountPercentage": 3, + "discountRange": 10 + } + }, + "title": "Rechnung", + "introduction": "Ihre bestellten Positionen stellen wir Ihnen hiermit in Rechnung", + "remark": "Vielen Dank für Ihren Einkauf" + } + """))); + + Invoice invoice = lexofficeApi.invoice().get(invoiceId); + + assertThat(invoice).isNotNull(); + assertThat(invoice.getId()).isEqualTo(invoiceId); + assertThat(invoice.getVoucherNumber()).isEqualTo("RE1019"); + assertThat(invoice.getVoucherStatus()).isEqualTo(VoucherStatus.OPEN); + assertThat(invoice.getAddress().getName()).isEqualTo("Berliner Kindl GmbH"); + assertThat(invoice.getLineItems()).hasSize(1); + assertThat(invoice.getLineItems().get(0).getName()).isEqualTo("Axa Rahmenschloss Defender RL"); + } + + @Test + void createInvoice() { + stubFor( + post(urlPathEqualTo("/v1/invoices")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "e9066f04-8cc7-4616-93f8-ac9571ec5e11", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-06-17T18:32:07.480+02:00", + "updatedDate": "2023-06-17T18:32:07.551+02:00", + "version": 0, + "voucherStatus": "draft" + } + """))); + + Invoice newInvoice = Invoice.builder().title("Rechnung").language("de").build(); + + Invoice createdInvoice = lexofficeApi.invoice().create().submit(newInvoice); + + assertThat(createdInvoice).isNotNull(); + assertThat(createdInvoice.getId()).isEqualTo("e9066f04-8cc7-4616-93f8-ac9571ec5e11"); + assertThat(createdInvoice.getVoucherStatus()).isEqualTo(VoucherStatus.DRAFT); + + verify( + postRequestedFor(urlPathEqualTo("/v1/invoices")) + .withRequestBody( + equalToJson( + """ + { + "title": "Rechnung", + "language": "de" + } + """, + true, + true))); + } + + @Test + void createInvoiceFinalized() { + stubFor( + post(urlPathEqualTo("/v1/invoices")) + .withQueryParam("finalize", equalTo("true")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "e9066f04-8cc7-4616-93f8-ac9571ec5e11", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-06-17T18:32:07.480+02:00", + "updatedDate": "2023-06-17T18:32:07.551+02:00", + "version": 0, + "voucherStatus": "open", + "voucherNumber": "RE1020" + } + """))); + + Invoice newInvoice = Invoice.builder().title("Rechnung").language("de").build(); + + Invoice createdInvoice = lexofficeApi.invoice().create().finalize(true).submit(newInvoice); + + assertThat(createdInvoice).isNotNull(); + assertThat(createdInvoice.getId()).isEqualTo("e9066f04-8cc7-4616-93f8-ac9571ec5e11"); + assertThat(createdInvoice.getVoucherStatus()).isEqualTo(VoucherStatus.OPEN); + assertThat(createdInvoice.getVoucherNumber()).isEqualTo("RE1020"); + + verify( + postRequestedFor(urlPathEqualTo("/v1/invoices")) + .withQueryParam("finalize", equalTo("true")) + .withRequestBody( + equalToJson( + """ + { + "title": "Rechnung", + "language": "de" + } + """, + true, + true))); + } +} diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/QuotationChainIntegrationTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/QuotationChainIntegrationTest.java new file mode 100644 index 0000000..7706f01 --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/QuotationChainIntegrationTest.java @@ -0,0 +1,202 @@ +package de.focus_shift.lexoffice.java.sdk.chain; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import de.focus_shift.lexoffice.java.sdk.LexofficeApi; +import de.focus_shift.lexoffice.java.sdk.LexofficeApiBuilder; +import de.focus_shift.lexoffice.java.sdk.model.ItemCreatedResult; +import de.focus_shift.lexoffice.java.sdk.model.Quotation; +import de.focus_shift.lexoffice.java.sdk.model.VoucherStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@WireMockTest +class QuotationChainIntegrationTest { + + private LexofficeApi lexofficeApi; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + String wiremockHost = wmRuntimeInfo.getHttpBaseUrl().replace("http://", "") + "/v1"; + + lexofficeApi = + new LexofficeApiBuilder().apiToken("test-api-key").protocol("http").host(wiremockHost).build(); + } + + @Test + void getQuotation() { + String quotationId = "424f784e-1f4e-439e-8f71-19673e6a8b9d"; + + stubFor( + get(urlPathEqualTo("/v1/quotations/" + quotationId)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "424f784e-1f4e-439e-8f71-19673e6a8b9d", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-04-11T14:15:22.123+02:00", + "updatedDate": "2023-04-11T14:17:45.456+02:00", + "version": 2, + "language": "de", + "archived": false, + "voucherStatus": "open", + "voucherNumber": "AG0021", + "voucherDate": "2023-04-11T00:00:00.000+02:00", + "expirationDate": "2023-05-11T00:00:00.000+02:00", + "address": { + "contactId": "97c5794f-8ab2-43ad-b459-c5980b055e4d", + "name": "Muster GmbH", + "street": "Musterstraße 42", + "zip": "12345", + "city": "Musterstadt", + "countryCode": "DE" + }, + "lineItems": [ + { + "id": "68569bfc-e5ae-472d-bbdf-6d51a82b1d2f", + "type": "custom", + "name": "Beratungsleistung", + "description": "Projektberatung und Konzepterstellung", + "quantity": 8.0000, + "unitName": "Stunden", + "unitPrice": { + "currency": "EUR", + "netAmount": 150.00, + "grossAmount": 178.50, + "taxRatePercentage": 19 + }, + "lineItemAmount": 1428.00 + } + ], + "totalPrice": { + "currency": "EUR", + "totalNetAmount": 1200.00, + "totalGrossAmount": 1428.00, + "totalTaxAmount": 228.00 + }, + "taxAmounts": [ + { + "taxRatePercentage": 19, + "taxAmount": 228.00, + "netAmount": 1200.00 + } + ], + "taxConditions": { + "taxType": "gross" + }, + "title": "Angebot", + "introduction": "Gerne unterbreiten wir Ihnen folgendes Angebot", + "remark": "Dieses Angebot ist 30 Tage gültig" + } + """))); + + Quotation quotation = lexofficeApi.quotation().get(quotationId); + + assertThat(quotation).isNotNull(); + assertThat(quotation.getId()).isEqualTo(quotationId); + assertThat(quotation.getVoucherNumber()).isEqualTo("AG0021"); + assertThat(quotation.getVoucherStatus()).isEqualTo(VoucherStatus.OPEN); + assertThat(quotation.getAddress().getName()).isEqualTo("Muster GmbH"); + assertThat(quotation.getLineItems()).hasSize(1); + assertThat(quotation.getLineItems().get(0).getName()).isEqualTo("Beratungsleistung"); + } + + @Test + void createQuotation() { + stubFor( + post(urlPathEqualTo("/v1/quotations")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "424f784e-1f4e-439e-8f71-19673e6a8b9d", + "resourceUri": "https://api.lexoffice.io/v1/quotations/424f784e-1f4e-439e-8f71-19673e6a8b9d", + "createdDate": "2023-04-11T14:15:22.123+02:00", + "updatedDate": "2023-04-11T14:15:22.123+02:00", + "version": 0 + } + """))); + + Quotation newQuotation = Quotation.builder().title("Angebot").language("de").build(); + + ItemCreatedResult result = lexofficeApi.quotation().create().submit(newQuotation); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo("424f784e-1f4e-439e-8f71-19673e6a8b9d"); + assertThat(result.getResourceUri()).contains("/v1/quotations/424f784e-1f4e-439e-8f71-19673e6a8b9d"); + assertThat(result.getVersion()).isEqualTo(0L); + + verify( + postRequestedFor(urlPathEqualTo("/v1/quotations")) + .withRequestBody( + equalToJson( + """ + { + "title": "Angebot", + "language": "de" + } + """, + true, + true))); + } + + @Test + void createQuotationFinalized() { + stubFor( + post(urlPathEqualTo("/v1/quotations")) + .withQueryParam("finalize", equalTo("true")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "424f784e-1f4e-439e-8f71-19673e6a8b9d", + "resourceUri": "https://api.lexoffice.io/v1/quotations/424f784e-1f4e-439e-8f71-19673e6a8b9d", + "createdDate": "2023-04-11T14:15:22.123+02:00", + "updatedDate": "2023-04-11T14:15:22.123+02:00", + "version": 1 + } + """))); + + Quotation newQuotation = Quotation.builder().title("Angebot").language("de").build(); + + ItemCreatedResult result = lexofficeApi.quotation().create().finalize(true).submit(newQuotation); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo("424f784e-1f4e-439e-8f71-19673e6a8b9d"); + assertThat(result.getVersion()).isEqualTo(1L); + + verify( + postRequestedFor(urlPathEqualTo("/v1/quotations")) + .withQueryParam("finalize", equalTo("true")) + .withRequestBody( + equalToJson( + """ + { + "title": "Angebot", + "language": "de" + } + """, + true, + true))); + } +} diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/RecurringTemplateChainIntegrationTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/RecurringTemplateChainIntegrationTest.java new file mode 100644 index 0000000..7e194ff --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/RecurringTemplateChainIntegrationTest.java @@ -0,0 +1,282 @@ +package de.focus_shift.lexoffice.java.sdk.chain; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import de.focus_shift.lexoffice.java.sdk.LexofficeApi; +import de.focus_shift.lexoffice.java.sdk.LexofficeApiBuilder; +import de.focus_shift.lexoffice.java.sdk.model.Page; +import de.focus_shift.lexoffice.java.sdk.model.RecurringTemplate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@WireMockTest +class RecurringTemplateChainIntegrationTest { + + private LexofficeApi lexofficeApi; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + String wiremockHost = wmRuntimeInfo.getHttpBaseUrl().replace("http://", "") + "/v1"; + + lexofficeApi = + new LexofficeApiBuilder().apiToken("test-api-key").protocol("http").host(wiremockHost).build(); + } + + @Test + void getRecurringTemplate() { + String templateId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + stubFor( + get(urlPathEqualTo("/v1/recurring-templates/" + templateId)) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-01-15T10:30:00.000+01:00", + "updatedDate": "2023-06-01T14:45:30.123+02:00", + "version": 5, + "language": "de", + "archived": false, + "address": { + "contactId": "97c5794f-8ab2-43ad-b459-c5980b055e4d", + "name": "Stammkunde GmbH", + "street": "Hauptstraße 1", + "zip": "10115", + "city": "Berlin", + "countryCode": "DE" + }, + "lineItems": [ + { + "id": "item-uuid-1234", + "type": "custom", + "name": "Monatliche Wartung", + "description": "Regelmäßige Systemwartung und Updates", + "quantity": 1.0000, + "unitName": "Pauschale", + "unitPrice": { + "currency": "EUR", + "netAmount": 500.00, + "grossAmount": 595.00, + "taxRatePercentage": 19 + }, + "lineItemAmount": 595.00 + } + ], + "totalPrice": { + "currency": "EUR", + "totalNetAmount": 500.00, + "totalGrossAmount": 595.00, + "totalTaxAmount": 95.00 + }, + "taxAmounts": [ + { + "taxRatePercentage": 19, + "taxAmount": 95.00, + "netAmount": 500.00 + } + ], + "taxConditions": { + "taxType": "gross" + }, + "paymentConditions": { + "paymentTermLabel": "Zahlbar innerhalb von 14 Tagen", + "paymentTermDuration": 14 + }, + "title": "Wartungsvertrag", + "introduction": "Hiermit stellen wir Ihnen die vereinbarte Wartungspauschale in Rechnung", + "remark": "Vielen Dank für Ihr Vertrauen", + "recurringTemplateSettings": { + "id": "settings-uuid-5678", + "startDate": "2023-01-01T00:00:00.000+01:00", + "endDate": "2024-12-31T00:00:00.000+01:00", + "finalize": true, + "shippingType": "service", + "executionInterval": "MONTHLY", + "executionStatus": "ACTIVE", + "lastExecutionFailed": false, + "lastExecutionErrorMessage": null, + "nextExecutionDate": "2023-07-01T00:00:00.000+02:00" + } + } + """))); + + RecurringTemplate template = lexofficeApi.recurringTemplates().get(templateId); + + assertThat(template).isNotNull(); + assertThat(template.getId()).isEqualTo(templateId); + assertThat(template.getTitle()).isEqualTo("Wartungsvertrag"); + assertThat(template.getAddress().getName()).isEqualTo("Stammkunde GmbH"); + assertThat(template.getLineItems()).hasSize(1); + assertThat(template.getLineItems().get(0).getName()).isEqualTo("Monatliche Wartung"); + assertThat(template.getRecurringTemplateSettings()).isNotNull(); + } + + @Test + void fetchRecurringTemplates() { + stubFor( + get(urlPathEqualTo("/v1/recurring-templates")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-01-15T10:30:00.000+01:00", + "updatedDate": "2023-06-01T14:45:30.123+02:00", + "version": 5, + "language": "de", + "archived": false, + "title": "Wartungsvertrag", + "address": { + "name": "Stammkunde GmbH" + } + }, + { + "id": "b2c3d4e5-f6g7-8901-bcde-f23456789012", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-02-20T09:00:00.000+01:00", + "updatedDate": "2023-05-15T16:30:00.456+02:00", + "version": 3, + "language": "de", + "archived": false, + "title": "Hosting-Abonnement", + "address": { + "name": "Web Services AG" + } + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 2, + "numberOfElements": 2, + "size": 25, + "number": 0 + } + """))); + + Page templatePage = lexofficeApi.recurringTemplates().fetch().get(); + + assertThat(templatePage).isNotNull(); + assertThat(templatePage.getContent()).hasSize(2); + assertThat(templatePage.getTotalElements()).isEqualTo(2); + assertThat(templatePage.getFirst()).isTrue(); + assertThat(templatePage.getLast()).isTrue(); + + assertThat(templatePage.getContent().get(0).getTitle()).isEqualTo("Wartungsvertrag"); + assertThat(templatePage.getContent().get(1).getTitle()).isEqualTo("Hosting-Abonnement"); + } + + @Test + void fetchRecurringTemplatesWithPagination() { + stubFor( + get(urlPathEqualTo("/v1/recurring-templates")) + .withQueryParam("page", equalTo("0")) + .withQueryParam("size", equalTo("10")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-01-15T10:30:00.000+01:00", + "updatedDate": "2023-06-01T14:45:30.123+02:00", + "version": 5, + "language": "de", + "archived": false, + "title": "Wartungsvertrag" + } + ], + "first": true, + "last": false, + "totalPages": 3, + "totalElements": 25, + "numberOfElements": 10, + "size": 10, + "number": 0 + } + """))); + + Page templatePage = + lexofficeApi.recurringTemplates().fetch().page(0).pageSize(10).get(); + + assertThat(templatePage).isNotNull(); + assertThat(templatePage.getTotalPages()).isEqualTo(3); + assertThat(templatePage.getTotalElements()).isEqualTo(25); + assertThat(templatePage.getSize()).isEqualTo(10); + assertThat(templatePage.getFirst()).isTrue(); + assertThat(templatePage.getLast()).isFalse(); + } + + @Test + void fetchRecurringTemplatesWithSorting() { + stubFor( + get(urlPathEqualTo("/v1/recurring-templates")) + .withQueryParam("sort", equalTo("createdDate,DESC")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "b2c3d4e5-f6g7-8901-bcde-f23456789012", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-02-20T09:00:00.000+01:00", + "updatedDate": "2023-05-15T16:30:00.456+02:00", + "version": 3, + "language": "de", + "archived": false, + "title": "Hosting-Abonnement" + }, + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "organizationId": "aa93e8a8-2aa3-470b-b914-caad8a255dd8", + "createdDate": "2023-01-15T10:30:00.000+01:00", + "updatedDate": "2023-06-01T14:45:30.123+02:00", + "version": 5, + "language": "de", + "archived": false, + "title": "Wartungsvertrag" + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 2, + "numberOfElements": 2, + "size": 25, + "number": 0 + } + """))); + + Page templatePage = + lexofficeApi.recurringTemplates().fetch().sortByCreatedDate(false).get(); + + assertThat(templatePage).isNotNull(); + assertThat(templatePage.getContent()).hasSize(2); + // First item should be the more recently created one (descending order) + assertThat(templatePage.getContent().get(0).getTitle()).isEqualTo("Hosting-Abonnement"); + assertThat(templatePage.getContent().get(1).getTitle()).isEqualTo("Wartungsvertrag"); + } +} diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChainIntegrationTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChainIntegrationTest.java new file mode 100644 index 0000000..b5fa9ab --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChainIntegrationTest.java @@ -0,0 +1,263 @@ +package de.focus_shift.lexoffice.java.sdk.chain; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import de.focus_shift.lexoffice.java.sdk.LexofficeApi; +import de.focus_shift.lexoffice.java.sdk.LexofficeApiBuilder; +import de.focus_shift.lexoffice.java.sdk.model.Page; +import de.focus_shift.lexoffice.java.sdk.model.Voucher; +import de.focus_shift.lexoffice.java.sdk.model.VoucherStatus; +import de.focus_shift.lexoffice.java.sdk.model.VoucherType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@WireMockTest +class VoucherListChainIntegrationTest { + + private LexofficeApi lexofficeApi; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + String wiremockHost = wmRuntimeInfo.getHttpBaseUrl().replace("http://", "") + "/v1"; + + lexofficeApi = + new LexofficeApiBuilder().apiToken("test-api-key").protocol("http").host(wiremockHost).build(); + } + + @Test + void getVoucherList() { + stubFor( + get(urlPathEqualTo("/v1/voucherlist")) + .withQueryParam("voucherType", equalTo("invoice")) + .withQueryParam("voucherStatus", equalTo("open")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "e9066f04-8cc7-4616-93f8-ac9571ec5e11", + "voucherType": "invoice", + "voucherStatus": "open", + "voucherNumber": "RE1019", + "voucherDate": "2023-02-22T00:00:00.000+01:00", + "createdDate": "2023-06-17T18:32:07.480+02:00", + "updatedDate": "2023-06-17T18:32:07.551+02:00", + "dueDate": "2023-03-08T00:00:00.000+01:00", + "contactId": "97c5794f-8ab2-43ad-b459-c5980b055e4d", + "contactName": "Berliner Kindl GmbH", + "totalAmount": 23.90, + "openAmount": 23.90, + "currency": "EUR", + "archived": false + }, + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "voucherType": "invoice", + "voucherStatus": "open", + "voucherNumber": "RE1020", + "voucherDate": "2023-02-23T00:00:00.000+01:00", + "createdDate": "2023-06-18T10:15:30.123+02:00", + "updatedDate": "2023-06-18T10:15:30.456+02:00", + "dueDate": "2023-03-09T00:00:00.000+01:00", + "contactId": "12345678-abcd-ef12-3456-7890abcdef12", + "contactName": "Test Kunde AG", + "totalAmount": 1190.00, + "openAmount": 500.00, + "currency": "EUR", + "archived": false + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 2, + "numberOfElements": 2, + "size": 25, + "number": 0, + "sort": [ + { + "property": "voucherDate", + "direction": "DESC" + } + ] + } + """))); + + Page voucherPage = + lexofficeApi.voucherList().voucherType(VoucherType.INVOICE).voucherStatus(VoucherStatus.OPEN).get(); + + assertThat(voucherPage).isNotNull(); + assertThat(voucherPage.getContent()).hasSize(2); + assertThat(voucherPage.getTotalElements()).isEqualTo(2); + assertThat(voucherPage.getFirst()).isTrue(); + assertThat(voucherPage.getLast()).isTrue(); + + Voucher firstVoucher = voucherPage.getContent().get(0); + assertThat(firstVoucher.getVoucherNumber()).isEqualTo("RE1019"); + assertThat(firstVoucher.getVoucherType()).isEqualTo(VoucherType.INVOICE); + assertThat(firstVoucher.getVoucherStatus()).isEqualTo(VoucherStatus.OPEN); + assertThat(firstVoucher.getContactName()).isEqualTo("Berliner Kindl GmbH"); + } + + @Test + void getVoucherListWithPagination() { + stubFor( + get(urlPathEqualTo("/v1/voucherlist")) + .withQueryParam("voucherType", equalTo("invoice")) + .withQueryParam("voucherStatus", equalTo("open")) + .withQueryParam("page", equalTo("0")) + .withQueryParam("size", equalTo("10")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "e9066f04-8cc7-4616-93f8-ac9571ec5e11", + "voucherType": "invoice", + "voucherStatus": "open", + "voucherNumber": "RE1019", + "voucherDate": "2023-02-22T00:00:00.000+01:00", + "contactName": "Berliner Kindl GmbH", + "totalAmount": 23.90, + "currency": "EUR", + "archived": false + } + ], + "first": true, + "last": false, + "totalPages": 5, + "totalElements": 50, + "numberOfElements": 10, + "size": 10, + "number": 0 + } + """))); + + Page voucherPage = + lexofficeApi + .voucherList() + .voucherType(VoucherType.INVOICE) + .voucherStatus(VoucherStatus.OPEN) + .page(0) + .pageSize(10) + .get(); + + assertThat(voucherPage).isNotNull(); + assertThat(voucherPage.getTotalPages()).isEqualTo(5); + assertThat(voucherPage.getTotalElements()).isEqualTo(50); + assertThat(voucherPage.getSize()).isEqualTo(10); + assertThat(voucherPage.getNumber()).isEqualTo(0); + assertThat(voucherPage.getFirst()).isTrue(); + assertThat(voucherPage.getLast()).isFalse(); + } + + @Test + void getVoucherListWithSorting() { + stubFor( + get(urlPathEqualTo("/v1/voucherlist")) + .withQueryParam("voucherType", equalTo("invoice")) + .withQueryParam("voucherStatus", equalTo("open")) + .withQueryParam("sort", equalTo("voucherDate,DESC")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "e9066f04-8cc7-4616-93f8-ac9571ec5e11", + "voucherType": "invoice", + "voucherStatus": "open", + "voucherNumber": "RE1019", + "voucherDate": "2023-02-22T00:00:00.000+01:00", + "contactName": "Berliner Kindl GmbH", + "totalAmount": 23.90, + "currency": "EUR", + "archived": false + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 1, + "numberOfElements": 1, + "size": 25, + "number": 0 + } + """))); + + Page voucherPage = + lexofficeApi + .voucherList() + .voucherType(VoucherType.INVOICE) + .voucherStatus(VoucherStatus.OPEN) + .sortByVoucherDate(false) + .get(); + + assertThat(voucherPage).isNotNull(); + assertThat(voucherPage.getContent()).hasSize(1); + } + + @Test + void getVoucherListFilteredByContactName() { + stubFor( + get(urlPathEqualTo("/v1/voucherlist")) + .withQueryParam("voucherType", equalTo("invoice")) + .withQueryParam("voucherStatus", equalTo("open")) + .withQueryParam("contactName", equalTo("Berliner Kindl")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "content": [ + { + "id": "e9066f04-8cc7-4616-93f8-ac9571ec5e11", + "voucherType": "invoice", + "voucherStatus": "open", + "voucherNumber": "RE1019", + "voucherDate": "2023-02-22T00:00:00.000+01:00", + "contactName": "Berliner Kindl GmbH", + "totalAmount": 23.90, + "currency": "EUR", + "archived": false + } + ], + "first": true, + "last": true, + "totalPages": 1, + "totalElements": 1, + "numberOfElements": 1, + "size": 25, + "number": 0 + } + """))); + + Page voucherPage = + lexofficeApi + .voucherList() + .voucherType(VoucherType.INVOICE) + .voucherStatus(VoucherStatus.OPEN) + .contactName("Berliner Kindl") + .get(); + + assertThat(voucherPage).isNotNull(); + assertThat(voucherPage.getContent()).hasSize(1); + assertThat(voucherPage.getContent().get(0).getContactName()).isEqualTo("Berliner Kindl GmbH"); + } +} From 38c16384981ac92576939c7e382b40fedd55a211 Mon Sep 17 00:00:00 2001 From: Johannes Graf Date: Mon, 2 Feb 2026 22:53:09 +0100 Subject: [PATCH 2/2] fix query param encoding --- .../lexoffice/java/sdk/RestUriBuilder.java | 64 ++--- .../java/sdk/SearchStringEncoder.java | 51 ++++ .../java/sdk/chain/ContactChain.java | 9 +- .../java/sdk/chain/VoucherListChain.java | 11 +- .../java/sdk/RestUriBuilderTest.java | 257 ++++++++++++++++++ .../java/sdk/SearchStringEncoderTest.java | 98 +++++++ .../chain/ContactChainIntegrationTest.java | 3 +- 7 files changed, 445 insertions(+), 48 deletions(-) create mode 100644 src/main/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoder.java create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilderTest.java create mode 100644 src/test/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoderTest.java diff --git a/src/main/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilder.java b/src/main/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilder.java index 8553b1e..f9926fc 100644 --- a/src/main/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilder.java +++ b/src/main/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilder.java @@ -1,23 +1,18 @@ package de.focus_shift.lexoffice.java.sdk; -import com.google.common.base.Function; -import com.google.common.base.Joiner; import com.google.common.base.Preconditions; -import com.google.common.collect.Iterables; -import com.google.common.collect.Lists; +import org.springframework.web.util.UriComponentsBuilder; +import java.net.URI; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; public class RestUriBuilder { - private String host; private String protocol = "https"; - private StringBuilder path; - private Map> parameters = new HashMap<>(); + private String host; + private String path = ""; + private final UriComponentsBuilder queryParamsBuilder = UriComponentsBuilder.newInstance(); public RestUriBuilder host(String host) { Preconditions.checkNotNull(host); @@ -33,14 +28,13 @@ public RestUriBuilder protocol(String protocol) { public RestUriBuilder path(String path) { Preconditions.checkNotNull(path); - this.path = new StringBuilder(); - this.path.append(path); + this.path = path; return this; } public RestUriBuilder appendPath(String path) { Preconditions.checkNotNull(path); - this.path.append(path); + this.path = this.path + path; return this; } @@ -48,40 +42,24 @@ public RestUriBuilder addParameter(String key, Object value) { return addParameters(key, Collections.singletonList(value)); } - public RestUriBuilder addParameters(String key, Collection value) { + public RestUriBuilder addParameters(String key, Collection values) { Preconditions.checkNotNull(key); - Preconditions.checkNotNull(value); - Iterable stringValues = Iterables.transform(value, new Function() { - public String apply(Object o) { - return String.valueOf(o); - } - }); - this.parameters.put(key, Lists.newArrayList(stringValues)); + Preconditions.checkNotNull(values); + for (Object value : values) { + queryParamsBuilder.queryParam(key, value); + } return this; } - public String build() { - StringBuilder builder = new StringBuilder(); - builder.append(protocol); - builder.append("://"); + public URI build() { Preconditions.checkNotNull(host); - Preconditions.checkNotNull(path); - builder.append(host); - builder.append(path); - if (!parameters.isEmpty()) { - builder.append("?"); - Joiner.on("&") - .appendTo(builder, Iterables.transform(parameters.entrySet(), new Function>, String>() { - public String apply(final Map.Entry> stringStringEntry) { - return Joiner.on("&") - .join(Lists.transform(stringStringEntry.getValue(), new Function() { - public String apply(String s) { - return stringStringEntry.getKey() + "=" + s; - } - })); - } - })); - } - return builder.toString(); + + String baseUrl = protocol + "://" + host + path; + + return UriComponentsBuilder.fromUriString(baseUrl) + .queryParams(queryParamsBuilder.build().getQueryParams()) + .build() + .encode() + .toUri(); } } diff --git a/src/main/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoder.java b/src/main/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoder.java new file mode 100644 index 0000000..1c5ce69 --- /dev/null +++ b/src/main/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoder.java @@ -0,0 +1,51 @@ +package de.focus_shift.lexoffice.java.sdk; + +/** + * Utility class for encoding search strings according to Lexoffice API requirements. + * + *

Due to technical constraints, for some endpoints (contact, vouchers, voucherlist), + * search strings require special handling: HTML special characters {@code &}, {@code <}, + * and {@code >} need to be sent in their HTML encoded form before URL encoding is applied. + * + *

The HTML encoding needs to match the canonical representation as stored in the + * Lexware database. Unicode character encoding or any other form of representation + * should not be used. + * + * @see + * Lexoffice API - Search String Encoding + */ +public final class SearchStringEncoder { + + private SearchStringEncoder() { + // utility class + } + + /** + * HTML encodes special characters in search strings for Lexoffice API endpoints + * that require this encoding (contact, vouchers, voucherlist). + * + *

The following characters are encoded: + *

    + *
  • {@code &} → {@code &}
  • + *
  • {@code <} → {@code <}
  • + *
  • {@code >} → {@code >}
  • + *
+ * + *

Example: {@code "johnson & partner"} becomes {@code "johnson & partner"} + * + * @param value the search string to encode, may be null + * @return the HTML encoded string, or null if input was null + * @see + * Lexoffice API - Search String Encoding + */ + public static String htmlEncode(String value) { + if (value == null) { + return null; + } + // Order matters: & must be encoded first to avoid double-encoding + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} diff --git a/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChain.java b/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChain.java index 49af491..d88952d 100644 --- a/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChain.java +++ b/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChain.java @@ -2,6 +2,7 @@ import de.focus_shift.lexoffice.java.sdk.RequestContext; +import de.focus_shift.lexoffice.java.sdk.SearchStringEncoder; import de.focus_shift.lexoffice.java.sdk.model.Contact; import de.focus_shift.lexoffice.java.sdk.model.ItemCreatedResult; import de.focus_shift.lexoffice.java.sdk.model.Page; @@ -68,19 +69,23 @@ public Fetch pageSize(int pageSize) { /** * filters contacts where any of their email addresses inside the emailAddresses JSON object match the given email value. At least 3 characters are necessary to successfully complete the query. + * + * @see SearchStringEncoder */ public Fetch email(String email) { super.getUriBuilder() - .addParameter("email", email); + .addParameter("email", SearchStringEncoder.htmlEncode(email)); return this; } /** * filters contacts whose name matches the given name value. At least 3 characters are necessary to successfully complete the query. + * + * @see SearchStringEncoder */ public Fetch name(String name) { super.getUriBuilder() - .addParameter("name", name); + .addParameter("name", SearchStringEncoder.htmlEncode(name)); return this; } diff --git a/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChain.java b/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChain.java index dee7787..7e4a23a 100644 --- a/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChain.java +++ b/src/main/java/de/focus_shift/lexoffice/java/sdk/chain/VoucherListChain.java @@ -3,6 +3,7 @@ import com.google.common.base.Joiner; import de.focus_shift.lexoffice.java.sdk.LexofficeApi; import de.focus_shift.lexoffice.java.sdk.RequestContext; +import de.focus_shift.lexoffice.java.sdk.SearchStringEncoder; import de.focus_shift.lexoffice.java.sdk.model.Page; import de.focus_shift.lexoffice.java.sdk.model.Voucher; import de.focus_shift.lexoffice.java.sdk.model.VoucherStatus; @@ -91,9 +92,12 @@ public VoucherListChain voucherStatus(VoucherStatus... voucherStatus) { return this; } + /** + * @see SearchStringEncoder + */ public VoucherListChain voucherNumber(String voucherNumber) { super.getUriBuilder() - .addParameter("voucherNumber", voucherNumber); + .addParameter("voucherNumber", SearchStringEncoder.htmlEncode(voucherNumber)); return this; } @@ -120,9 +124,12 @@ public VoucherListChain dueDate(Date dueDate) { return this; } + /** + * @see SearchStringEncoder + */ public VoucherListChain contactName(String contactName) { super.getUriBuilder() - .addParameter("contactName", contactName); + .addParameter("contactName", SearchStringEncoder.htmlEncode(contactName)); return this; } diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilderTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilderTest.java new file mode 100644 index 0000000..6c9515a --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/RestUriBuilderTest.java @@ -0,0 +1,257 @@ +package de.focus_shift.lexoffice.java.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RestUriBuilderTest { + + private static final String LEXOFFICE_API_HOST = "api.lexware.io/v1"; + + @Test + void buildSimpleUrl() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1/contacts"); + } + + @Test + void buildUrlWithSingleQueryParameter() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("page", 0) + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1/contacts?page=0"); + } + + @Test + void buildUrlWithMultipleQueryParameters() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("page", 0) + .addParameter("size", 25) + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1/contacts?page=0&size=25"); + } + + @Test + void buildUrlWithMultipleValuesForSameKey() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/voucherlist") + .addParameters("voucherStatus", List.of("open", "paid")) + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1/voucherlist?voucherStatus=open&voucherStatus=paid"); + } + + @Test + void buildUrlEncodesSpecialCharactersInQueryParameters() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("name", "Schmidt & Partner, München") + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1/contacts?name=Schmidt%20%26%20Partner,%20M%C3%BCnchen"); + } + + @Test + void buildUrlEncodesAmpersandInQueryParameters() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("name", "Müller & Söhne GmbH") + .build(); + + assertThat(uri.toString()).contains("name=M%C3%BCller%20%26%20S%C3%B6hne%20GmbH"); + } + + @Test + void buildUrlEncodesEqualsSignInQueryParameters() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("filter", "status=active") + .build(); + + assertThat(uri.toString()).contains("filter=status%3Dactive"); + } + + @Test + void buildUrlEncodesSpacesInQueryParameters() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("name", "Berliner Kindl GmbH") + .build(); + + assertThat(uri.toString()).contains("name=Berliner%20Kindl%20GmbH"); + } + + @Test + void buildUrlEncodesPlusSignInQueryParameters() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("name", "Software C++ GmbH") + .build(); + + // Spring's UriComponentsBuilder keeps + as-is (valid in RFC 3986 query strings) + // When decoded, servers should interpret this correctly + assertThat(uri.toString()).contains("name=Software%20C++%20GmbH"); + } + + @Test + void buildUrlEncodesHashInQueryParameters() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("tag", "#wichtig") + .build(); + + assertThat(uri.toString()).contains("tag=%23wichtig"); + } + + @Test + void buildUrlWithHttpProtocol() { + URI uri = new RestUriBuilder() + .protocol("http") + .host("localhost:8080/v1") + .path("/contacts") + .build(); + + assertThat(uri.toString()).isEqualTo("http://localhost:8080/v1/contacts"); + } + + @Test + void appendPathConcatenatesCorrectly() { + String contactId = "be9475a4-ef80-442b-95eb-e56a7b2d5596"; + + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .appendPath("/") + .appendPath(contactId) + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1/contacts/be9475a4-ef80-442b-95eb-e56a7b2d5596"); + } + + @Test + void buildWithEmptyPathSucceeds() { + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1"); + } + + @Test + void buildWithoutHostThrowsException() { + RestUriBuilder builder = new RestUriBuilder() + .protocol("https") + .path("/contacts"); + + assertThatThrownBy(builder::build) + .isInstanceOf(NullPointerException.class); + } + + @Test + void hostCannotBeNull() { + RestUriBuilder builder = new RestUriBuilder(); + + assertThatThrownBy(() -> builder.host(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void protocolCannotBeNull() { + RestUriBuilder builder = new RestUriBuilder(); + + assertThatThrownBy(() -> builder.protocol(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void pathCannotBeNull() { + RestUriBuilder builder = new RestUriBuilder(); + + assertThatThrownBy(() -> builder.path(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void appendPathCannotBeNull() { + RestUriBuilder builder = new RestUriBuilder(); + + assertThatThrownBy(() -> builder.appendPath(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void parameterKeyCannotBeNull() { + RestUriBuilder builder = new RestUriBuilder(); + + assertThatThrownBy(() -> builder.addParameter(null, "Berliner Kindl GmbH")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void parameterWithNullValueIncludesKeyOnly() { + // Spring's UriComponentsBuilder accepts null values and includes the key without a value + URI uri = new RestUriBuilder() + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .addParameter("archived", null) + .addParameter("customer", "true") + .build(); + + assertThat(uri.toString()).isEqualTo("https://api.lexware.io/v1/contacts?archived&customer=true"); + } + + @Test + void parametersCollectionCannotBeNull() { + RestUriBuilder builder = new RestUriBuilder(); + + assertThatThrownBy(() -> builder.addParameters("voucherStatus", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void builderIsFluent() { + RestUriBuilder builder = new RestUriBuilder(); + + RestUriBuilder result = builder + .protocol("https") + .host(LEXOFFICE_API_HOST) + .path("/contacts") + .appendPath("/be9475a4-ef80-442b-95eb-e56a7b2d5596") + .addParameter("page", 0); + + assertThat(result).isSameAs(builder); + } +} diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoderTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoderTest.java new file mode 100644 index 0000000..3ad90a3 --- /dev/null +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/SearchStringEncoderTest.java @@ -0,0 +1,98 @@ +package de.focus_shift.lexoffice.java.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SearchStringEncoderTest { + + @Test + void htmlEncodeReturnsNullForNullInput() { + assertThat(SearchStringEncoder.htmlEncode(null)).isNull(); + } + + @Test + void htmlEncodeReturnsEmptyStringForEmptyInput() { + assertThat(SearchStringEncoder.htmlEncode("")).isEqualTo(""); + } + + @Test + void htmlEncodeReturnsUnchangedStringWithoutSpecialCharacters() { + assertThat(SearchStringEncoder.htmlEncode("Berliner Kindl GmbH")) + .isEqualTo("Berliner Kindl GmbH"); + } + + @Test + void htmlEncodeEncodesAmpersand() { + assertThat(SearchStringEncoder.htmlEncode("johnson & partner")) + .isEqualTo("johnson & partner"); + } + + @Test + void htmlEncodeEncodesLessThan() { + assertThat(SearchStringEncoder.htmlEncode("value < 100")) + .isEqualTo("value < 100"); + } + + @Test + void htmlEncodeEncodesGreaterThan() { + assertThat(SearchStringEncoder.htmlEncode("value > 50")) + .isEqualTo("value > 50"); + } + + @Test + void htmlEncodeEncodesMultipleSpecialCharacters() { + assertThat(SearchStringEncoder.htmlEncode("a < b & c > d")) + .isEqualTo("a < b & c > d"); + } + + @Test + void htmlEncodeEncodesMultipleAmpersands() { + assertThat(SearchStringEncoder.htmlEncode("Müller & Söhne & Partner GmbH")) + .isEqualTo("Müller & Söhne & Partner GmbH"); + } + + @Test + void htmlEncodePreservesUmlauts() { + assertThat(SearchStringEncoder.htmlEncode("Müller & Söhne")) + .isEqualTo("Müller & Söhne"); + } + + @Test + void htmlEncodeDoesNotDoubleEncodeAlreadyEncodedAmpersand() { + // If someone passes already encoded text, it will be encoded again + // This is expected behavior - the input should be raw text + assertThat(SearchStringEncoder.htmlEncode("&")) + .isEqualTo("&amp;"); + } + + @Test + void htmlEncodeHandlesOnlyAmpersand() { + assertThat(SearchStringEncoder.htmlEncode("&")) + .isEqualTo("&"); + } + + @Test + void htmlEncodeHandlesOnlyLessThan() { + assertThat(SearchStringEncoder.htmlEncode("<")) + .isEqualTo("<"); + } + + @Test + void htmlEncodeHandlesOnlyGreaterThan() { + assertThat(SearchStringEncoder.htmlEncode(">")) + .isEqualTo(">"); + } + + @Test + void htmlEncodeHandlesConsecutiveSpecialCharacters() { + assertThat(SearchStringEncoder.htmlEncode("<<&&>>")) + .isEqualTo("<<&&>>"); + } + + @Test + void htmlEncodePreservesWhitespace() { + assertThat(SearchStringEncoder.htmlEncode(" Schmidt & Partner ")) + .isEqualTo(" Schmidt & Partner "); + } +} diff --git a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java index 052d0d3..ba47eba 100644 --- a/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java +++ b/src/test/java/de/focus_shift/lexoffice/java/sdk/chain/ContactChainIntegrationTest.java @@ -246,10 +246,11 @@ void fetchContactsFilteredByName() { @Test void fetchContactsFilteredByNameWithSpecialCharacters() { String companyName = "Müller & Söhne GmbH"; + String htmlEncodedName = "Müller & Söhne GmbH"; stubFor( get(urlPathEqualTo("/v1/contacts")) - .withQueryParam("name", equalTo(companyName)) + .withQueryParam("name", equalTo(htmlEncodedName)) .willReturn( aResponse() .withHeader("Content-Type", "application/json")