diff --git a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java index 7024a55..b8eeb58 100644 --- a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java +++ b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java @@ -1,188 +1,229 @@ package org.opendevstack.apiservice.externalservice.bitbucket.client; +import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration; import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration.BitbucketInstanceConfig; import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; -import lombok.extern.slf4j.Slf4j; import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import javax.net.ssl.*; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.cert.X509Certificate; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.Set; /** - * Factory for creating BitbucketApiClient instances. + * Factory for creating {@link BitbucketApiClient} instances. * Uses the factory pattern to provide configured clients for different Bitbucket instances. * Clients are cached and reused for efficiency. */ @Component @Slf4j public class BitbucketApiClientFactory { - + private final BitbucketServiceConfiguration configuration; - private final Map clientCache; private final RestTemplateBuilder restTemplateBuilder; - + /** - * Constructor with dependency injection - * - * @param configuration Bitbucket service configuration + * Constructor with dependency injection. + * + * @param configuration Bitbucket service configuration * @param restTemplateBuilder RestTemplate builder for creating HTTP clients */ - public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration, + public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration, RestTemplateBuilder restTemplateBuilder) { this.configuration = configuration; this.restTemplateBuilder = restTemplateBuilder; - this.clientCache = new ConcurrentHashMap<>(); - - log.info("BitbucketApiClientFactory initialized with {} instance(s)", + + log.info("BitbucketApiClientFactory initialized with {} instance(s)", configuration.getInstances().size()); } - + /** - * Get a BitbucketApiClient for a specific instance - * + * Resolve the effective instance name. + * + * + * @return The resolved default instance name (never {@code null}/blank) + * @throws BitbucketException if no Bitbucket instances are configured + */ + public String getDefaultInstanceName() throws BitbucketException { + + String defaultInstance = configuration.getDefaultInstance(); + if (defaultInstance != null && !defaultInstance.isBlank()) { + return defaultInstance; + } + + Map instances = configuration.getInstances(); + if (instances == null || instances.isEmpty()) { + throw new BitbucketException("No Bitbucket instances configured"); + } + + return instances.keySet().iterator().next(); + } + + /** + * Get a {@link BitbucketApiClient} for a specific instance. + * If {@code instanceName} is {@code null} or blank, a {@link BitbucketException} is thrown. + * * @param instanceName Name of the Bitbucket instance * @return Configured BitbucketApiClient - * @throws BitbucketException if the instance is not configured + * @throws BitbucketException if the instance name is null/blank or not configured */ + @Cacheable(value = "bitbucketApiClients", key = "#instanceName", + condition = "#instanceName != null && !#instanceName.isBlank()") public BitbucketApiClient getClient(String instanceName) throws BitbucketException { - // Check cache first - if (clientCache.containsKey(instanceName)) { - log.debug("Returning cached client for instance '{}'", instanceName); - return clientCache.get(instanceName); + if (instanceName == null || instanceName.isBlank()) { + throw new BitbucketException( + String.format("Provide instance name. Available instances: %s", + configuration.getInstances().keySet())); } - - // Create new client + BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); - + if (instanceConfig == null) { throw new BitbucketException( - String.format("Bitbucket instance '%s' is not configured. Available instances: %s", - instanceName, configuration.getInstances().keySet()) - ); + String.format("Bitbucket instance '%s' is not configured. Available instances: %s", + instanceName, configuration.getInstances().keySet())); } - + log.info("Creating new BitbucketApiClient for instance '{}'", instanceName); - + RestTemplate restTemplate = createRestTemplate(instanceConfig); - BitbucketApiClient client = new BitbucketApiClient(instanceName, instanceConfig, restTemplate); - - // Cache the client - clientCache.put(instanceName, client); - - return client; + return new BitbucketApiClient(instanceName, instanceConfig, restTemplate); } - + /** - * Get the default client (first configured instance) - * - * @return BitbucketApiClient for the first configured instance + * Get the default client, as determined by {@code externalservices.bitbucket.default-instance}. + * Falls back to the first configured instance when {@code default-instance} is not set. + * + * @return BitbucketApiClient for the default instance * @throws BitbucketException if no instances are configured */ - public BitbucketApiClient getDefaultClient() throws BitbucketException { - if (configuration.getInstances().isEmpty()) { - throw new BitbucketException("No Bitbucket instances configured"); - } - - String firstInstanceName = configuration.getInstances().keySet().iterator().next(); - log.debug("Using default instance: '{}'", firstInstanceName); - - return getClient(firstInstanceName); + @Cacheable(value = "bitbucketApiClients", key = "'default'") + public BitbucketApiClient getClient() throws BitbucketException { + String defaultInstanceName = getDefaultInstanceName(); + BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName); + RestTemplate restTemplate = createRestTemplate(instanceConfig); + + return new BitbucketApiClient(defaultInstanceName, instanceConfig, restTemplate); } - + /** - * Get all available instance names - * + * Get all available instance names. + * * @return Set of configured instance names */ - public java.util.Set getAvailableInstances() { + public Set getAvailableInstances() { return configuration.getInstances().keySet(); } - + /** - * Check if an instance is configured - * + * Check if an instance is configured. + * * @param instanceName Name of the instance to check * @return true if configured, false otherwise */ public boolean hasInstance(String instanceName) { return configuration.getInstances().containsKey(instanceName); } - + /** - * Clear the client cache (useful for testing or when configuration changes) + * Clear the client cache (useful for testing or when configuration changes). */ + @CacheEvict(value = "bitbucketApiClients", allEntries = true) public void clearCache() { log.info("Clearing BitbucketApiClient cache"); - clientCache.clear(); } /** - * Create a configured RestTemplate for a Bitbucket instance - * + * Create a configured RestTemplate for a Bitbucket instance. + * * @param config Configuration for the instance * @return Configured RestTemplate */ private RestTemplate createRestTemplate(BitbucketInstanceConfig config) { RestTemplate restTemplate = restTemplateBuilder.build(); - // Set timeouts using SimpleClientHttpRequestFactory - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(config.getConnectionTimeout()); - requestFactory.setReadTimeout(config.getReadTimeout()); - restTemplate.setRequestFactory(requestFactory); - - // Configure SSL if trustAllCertificates is enabled if (config.isTrustAllCertificates()) { log.warn("Trust all certificates is enabled for Bitbucket connection. " + "This should only be used in development environments!"); - configureTrustAllCertificates(restTemplate); + restTemplate.setRequestFactory(createTrustAllRequestFactory(config)); + } else { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(config.getConnectionTimeout()); + requestFactory.setReadTimeout(config.getReadTimeout()); + restTemplate.setRequestFactory(requestFactory); } return restTemplate; } - + /** - * Configure RestTemplate to trust all SSL certificates - * WARNING: This should only be used in development environments - * - * @param restTemplate RestTemplate to configure + * Create a {@link SimpleClientHttpRequestFactory} that trusts all SSL certificates + * only for this specific RestTemplate, without modifying the JVM-wide defaults. + *

+ * WARNING: This should only be used in development environments. + * + * @param config Instance configuration (for timeouts) + * @return A request factory whose connections skip SSL verification */ @SuppressWarnings({"java:S4830", "java:S1186"}) // Intentionally disabling SSL validation for development - private void configureTrustAllCertificates(RestTemplate restTemplate) { + private SimpleClientHttpRequestFactory createTrustAllRequestFactory(BitbucketInstanceConfig config) { try { TrustManager[] trustAllCerts = new TrustManager[]{ new X509TrustManager() { public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } - // Intentionally empty - trusting all certificates for development environments public void checkClientTrusted(X509Certificate[] certs, String authType) { // No validation performed - development only } - // Intentionally empty - trusting all certificates for development environments public void checkServerTrusted(X509Certificate[] certs, String authType) { // No validation performed - development only } } }; - + SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); - // Intentionally disabling hostname verification for development environments - HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); - + + final javax.net.ssl.SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + final javax.net.ssl.HostnameVerifier trustAllHostnames = (hostname, session) -> true; + + // Override prepareConnection so SSL settings apply only to this RestTemplate + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(java.net.HttpURLConnection connection, String httpMethod) throws java.io.IOException { + if (connection instanceof HttpsURLConnection httpsConnection) { + httpsConnection.setSSLSocketFactory(sslSocketFactory); + httpsConnection.setHostnameVerifier(trustAllHostnames); + } + super.prepareConnection(connection, httpMethod); + } + }; + requestFactory.setConnectTimeout(config.getConnectionTimeout()); + requestFactory.setReadTimeout(config.getReadTimeout()); + return requestFactory; + } catch (NoSuchAlgorithmException | KeyManagementException e) { - log.error("Failed to configure SSL trust all certificates", e); + log.error("Failed to configure SSL trust all certificates, falling back to default factory", e); + SimpleClientHttpRequestFactory fallback = new SimpleClientHttpRequestFactory(); + fallback.setConnectTimeout(config.getConnectionTimeout()); + fallback.setReadTimeout(config.getReadTimeout()); + return fallback; } } } diff --git a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java index 65967f3..3b81f62 100644 --- a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java +++ b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java @@ -16,6 +16,13 @@ @Data public class BitbucketServiceConfiguration { + /** + * Optional name of the default Bitbucket instance. + * When set, {@code BitbucketApiClientFactory#getClient()} will use this instance. + * If not set, the first entry in the instances map is used as default. + */ + private String defaultInstance; + /** * Map of Bitbucket instances with instance name as key and configuration as value. * Example: @@ -23,11 +30,11 @@ public class BitbucketServiceConfiguration { * bitbucket: * instances: * dev: - * base-url: https://bitbucket.dev.example.com + * base-url: "https://bitbucket.dev.example.com" * username: admin * password: password123 * prod: - * base-url: https://bitbucket.example.com + * base-url: "https://bitbucket.example.com" * username: admin * password: secret */ @@ -39,7 +46,7 @@ public class BitbucketServiceConfiguration { @Data public static class BitbucketInstanceConfig { /** - * The base URL of the Bitbucket server (e.g., https://bitbucket.example.com) + * The base URL of the Bitbucket server (e.g., "https://bitbucket.example.com") */ private String baseUrl; diff --git a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketService.java b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketService.java index 1f4224d..c6f405d 100644 --- a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketService.java +++ b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketService.java @@ -34,6 +34,18 @@ public interface BitbucketService extends ExternalService { */ boolean branchExists(String instanceName, String projectKey, String repositorySlug, String branchName) throws BitbucketException; + /** + * Check if a project exists in a specific Bitbucket instance. + * + * @param instanceName Name of the Bitbucket instance + * @param projectKey Project key (e.g., "PROJ") + * @return true if the project exists, false if it does not exist + * @throws BitbucketException if the check fails due to a non-functional error + * (e.g., Bitbucket unreachable, bad credentials, network errors). + * A non-existent project is NOT surfaced as an exception — it returns false. + */ + boolean projectExists(String instanceName, String projectKey) throws BitbucketException; + /** * Get all available Bitbucket instance names * diff --git a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/impl/BitbucketServiceImpl.java b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/impl/BitbucketServiceImpl.java index 294ad4c..a53b905 100644 --- a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/impl/BitbucketServiceImpl.java +++ b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/service/impl/BitbucketServiceImpl.java @@ -60,6 +60,13 @@ public String getDefaultBranch(String instanceName, String projectKey, String re throw new BitbucketException( String.format("No default branch found for repository '%s/%s'", projectKey, repositorySlug)); + } catch (HttpClientErrorException.Unauthorized e) { + log.error("Authentication failed for instance '{}' while retrieving default branch for '{}/{}'", + instanceName, projectKey, repositorySlug); + throw new BitbucketException( + String.format("Authentication failed (401 Unauthorized) for instance '%s'. " + + "Check your credentials (username/password or bearer token).", instanceName), + e); } catch (HttpClientErrorException.NotFound e) { throw new BitbucketException( String.format("Repository '%s/%s' not found or has no default branch", projectKey, repositorySlug), @@ -115,6 +122,13 @@ public boolean branchExists(String instanceName, String projectKey, String repos return false; + } catch (HttpClientErrorException.Unauthorized e) { + log.error("Authentication failed for instance '{}' while checking branch '{}' in '{}/{}'", + instanceName, branchName, projectKey, repositorySlug); + throw new BitbucketException( + String.format("Authentication failed (401 Unauthorized) for instance '%s'. " + + "Check your credentials (username/password or bearer token).", instanceName), + e); } catch (HttpClientErrorException.NotFound e) { // Repository not found log.debug("Repository '{}/{}' not found", projectKey, repositorySlug); @@ -129,6 +143,40 @@ public boolean branchExists(String instanceName, String projectKey, String repos } } + @Override + public boolean projectExists(String instanceName, String projectKey) throws BitbucketException { + log.debug("Checking if project '{}' exists in instance '{}'", projectKey, instanceName); + + try { + BitbucketApiClient bitbucketClient = clientFactory.getClient(instanceName); + ApiClient apiClient = bitbucketClient.getApiClient(); + + ProjectApi projectApi = new ProjectApi(apiClient); + projectApi.getProject(projectKey); + + log.debug("Project '{}' exists in instance '{}'", projectKey, instanceName); + return true; + + } catch (HttpClientErrorException.Unauthorized e) { + log.error("Authentication failed for instance '{}' while checking project '{}'", + instanceName, projectKey); + throw new BitbucketException( + String.format("Authentication failed (401 Unauthorized) for instance '%s'. " + + "Check your credentials (username/password or bearer token).", instanceName), + e); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Project '{}' does not exist in instance '{}'", projectKey, instanceName); + return false; + + } catch (RestClientException e) { + log.error("Error checking if project '{}' exists in instance '{}'", projectKey, instanceName, e); + throw new BitbucketException( + String.format("Failed to check if project '%s' exists in instance '%s'", + projectKey, instanceName), + e); + } + } + @Override public Set getAvailableInstances() { return clientFactory.getAvailableInstances(); diff --git a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactoryTest.java b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactoryTest.java new file mode 100644 index 0000000..8362c16 --- /dev/null +++ b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactoryTest.java @@ -0,0 +1,207 @@ +package org.opendevstack.apiservice.externalservice.bitbucket.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration; +import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration.BitbucketInstanceConfig; +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.web.client.RestTemplate; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link BitbucketApiClientFactory}. + * Focuses on default-instance resolution logic and client creation. + */ +@ExtendWith(MockitoExtension.class) +class BitbucketApiClientFactoryTest { + + @Mock + private RestTemplateBuilder restTemplateBuilder; + + @Mock + private RestTemplate restTemplate; + + private BitbucketServiceConfiguration configuration; + + @BeforeEach + void setUp() { + configuration = new BitbucketServiceConfiguration(); + lenient().when(restTemplateBuilder.build()).thenReturn(restTemplate); + } + + private BitbucketApiClientFactory factory() { + return new BitbucketApiClientFactory(configuration, restTemplateBuilder); + } + + // ------------------------------------------------------------------------- + // getDefaultInstanceName → configured default + // ------------------------------------------------------------------------- + + @Test + void getDefaultInstanceName_returnsConfiguredDefaultInstance() throws BitbucketException { + configuration.setDefaultInstance("prod"); + configuration.setInstances(Map.of("prod", config("https://bitbucket.prod.example.com"))); + + assertEquals("prod", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // getDefaultInstanceName – without default → fallback to first instance + // ------------------------------------------------------------------------- + + @Test + void getDefaultInstanceName_noDefaultConfigured_returnsFirstInstance() throws BitbucketException { + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://bitbucket-alpha.example.com")); + instances.put("beta", config("https://bitbucket-beta.example.com")); + configuration.setInstances(instances); + + assertEquals("alpha", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // getDefaultInstanceName – no instances at all → exception + // ------------------------------------------------------------------------- + + @Test + void getDefaultInstanceName_noInstancesConfigured_throwsBitbucketException() { + BitbucketApiClientFactory f = factory(); + + BitbucketException ex = assertThrows(BitbucketException.class, f::getDefaultInstanceName); + assertTrue(ex.getMessage().toLowerCase().contains("no bitbucket instances configured"), + "Expected 'no bitbucket instances configured' in: " + ex.getMessage()); + } + + // ------------------------------------------------------------------------- + // getClient(null) – should throw + // ------------------------------------------------------------------------- + + @Test + void getClient_null_throwsBitbucketException() { + BitbucketException ex = assertThrows(BitbucketException.class, () -> factory().getClient(null)); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_blank_throwsBitbucketException() { + BitbucketException ex = assertThrows(BitbucketException.class, () -> factory().getClient("")); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_unknownInstance_throwsBitbucketException() { + configuration.setInstances(Map.of("dev", config("https://bitbucket.dev.example.com"))); + + BitbucketException ex = assertThrows(BitbucketException.class, + () -> factory().getClient("nonexistent")); + assertTrue(ex.getMessage().contains("not configured")); + assertTrue(ex.getMessage().contains("nonexistent")); + } + + // ------------------------------------------------------------------------- + // getClient(instanceName) – valid instance + // ------------------------------------------------------------------------- + + @Test + void getClient_validInstance_returnsClient() throws BitbucketException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + configuration.setInstances(Map.of("dev", config("https://bitbucket.dev.example.com"))); + + BitbucketApiClient client = factory().getClient("dev"); + + assertNotNull(client); + assertEquals("dev", client.getInstanceName()); + } + + // ------------------------------------------------------------------------- + // getClient() – convenience method for default instance + // ------------------------------------------------------------------------- + + @Test + void getClient_returnsClientForConfiguredDefaultInstance() throws BitbucketException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + configuration.setDefaultInstance("prod"); + configuration.setInstances(orderedMap("dev", "prod")); + + BitbucketApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("prod", client.getInstanceName()); + } + + @Test + void getClient_noDefaultConfigured_returnsFirstInstance() throws BitbucketException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://bitbucket-alpha.example.com")); + instances.put("beta", config("https://bitbucket-beta.example.com")); + configuration.setInstances(instances); + + BitbucketApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("alpha", client.getInstanceName()); + } + + @Test + void getClient_noInstancesConfigured_throwsBitbucketException() { + BitbucketException ex = assertThrows(BitbucketException.class, () -> factory().getClient()); + assertTrue(ex.getMessage().toLowerCase().contains("no bitbucket instances configured")); + } + + // ------------------------------------------------------------------------- + // getAvailableInstances & hasInstance + // ------------------------------------------------------------------------- + + @Test + void getAvailableInstances_returnsConfiguredNames() { + configuration.setInstances(orderedMap("dev", "prod")); + + assertEquals(2, factory().getAvailableInstances().size()); + assertTrue(factory().getAvailableInstances().contains("dev")); + assertTrue(factory().getAvailableInstances().contains("prod")); + } + + @Test + void hasInstance_returnsTrueForConfigured() { + configuration.setInstances(Map.of("dev", config("https://bitbucket.dev.example.com"))); + + assertTrue(factory().hasInstance("dev")); + assertFalse(factory().hasInstance("nonexistent")); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static BitbucketInstanceConfig config(String baseUrl) { + BitbucketInstanceConfig c = new BitbucketInstanceConfig(); + c.setBaseUrl(baseUrl); + return c; + } + + /** Creates a LinkedHashMap with two configs using their names as base-url stems. */ + private static Map orderedMap(String first, String second) { + Map m = new LinkedHashMap<>(); + m.put(first, config("https://" + first + ".example.com")); + m.put(second, config("https://" + second + ".example.com")); + return m; + } +} + diff --git a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketApiClientFactoryCacheIntegrationTest.java b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketApiClientFactoryCacheIntegrationTest.java new file mode 100644 index 0000000..dbf4835 --- /dev/null +++ b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketApiClientFactoryCacheIntegrationTest.java @@ -0,0 +1,103 @@ +package org.opendevstack.apiservice.externalservice.bitbucket.integration; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.externalservice.bitbucket.client.BitbucketApiClient; +import org.opendevstack.apiservice.externalservice.bitbucket.client.BitbucketApiClientFactory; +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Integration tests for the {@link BitbucketApiClientFactory} client cache. + * + *

The {@code @Cacheable} annotation on {@link BitbucketApiClientFactory#getClient(String)} is only + * activated by the Spring AOP proxy, so caching behaviour must be verified with a real + * {@link org.springframework.boot.test.context.SpringBootTest} context rather than a plain + * Mockito unit test. + * + *

Tests define two lightweight, fake Bitbucket instances via {@link TestPropertySource}. + * No real Bitbucket connectivity is required – the clients are created but never used to make + * HTTP calls. + */ +@SpringBootTest(classes = BitbucketIntegrationTestConfig.class) +@TestPropertySource(properties = { + "externalservices.bitbucket.instances.dev.base-url=https://bitbucket.dev.example.com", + "externalservices.bitbucket.instances.staging.base-url=https://bitbucket.staging.example.com" +}) +class BitbucketApiClientFactoryCacheIntegrationTest { + + @Autowired + private BitbucketApiClientFactory factory; + + // ------------------------------------------------------------------------- + // Same instance name → same cached instance + // ------------------------------------------------------------------------- + + @Test + void getClient_sameInstanceName_returnsCachedInstance() throws BitbucketException { + BitbucketApiClient first = factory.getClient("dev"); + BitbucketApiClient second = factory.getClient("dev"); + + assertSame(first, second, + "Repeated calls with the same instance name must return the cached client"); + } + + @Test + void getClient_sameInstanceName_multipleCallsAlwaysReturnSameInstance() throws BitbucketException { + BitbucketApiClient reference = factory.getClient("staging"); + + for (int i = 0; i < 5; i++) { + assertSame(reference, factory.getClient("staging"), + "Call #" + (i + 1) + " should return the same cached client for 'staging'"); + } + } + + // ------------------------------------------------------------------------- + // Different instance names → different instances + // ------------------------------------------------------------------------- + + @Test + void getClient_differentInstanceNames_returnDifferentInstances() throws BitbucketException { + BitbucketApiClient dev = factory.getClient("dev"); + BitbucketApiClient staging = factory.getClient("staging"); + + assertNotSame(dev, staging, + "Different instance names must produce distinct client objects"); + assertEquals("dev", dev.getInstanceName()); + assertEquals("staging", staging.getInstanceName()); + } + + // ------------------------------------------------------------------------- + // Default client → cached + // ------------------------------------------------------------------------- + + @Test + void getDefaultClient_returnsCachedInstance() throws BitbucketException { + BitbucketApiClient first = factory.getClient(); + BitbucketApiClient second = factory.getClient(); + + assertSame(first, second, + "Repeated calls to getClient() must return the cached default client"); + } + + // ------------------------------------------------------------------------- + // clearCache → evicts all cached clients + // ------------------------------------------------------------------------- + + @Test + void clearCache_evictsCachedClients() throws BitbucketException { + BitbucketApiClient before = factory.getClient("dev"); + + factory.clearCache(); + + BitbucketApiClient after = factory.getClient("dev"); + assertNotSame(before, after, + "After clearCache(), a new client instance must be created"); + } +} + diff --git a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketIntegrationTestConfig.java b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketIntegrationTestConfig.java index 6e668db..52d41f7 100644 --- a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketIntegrationTestConfig.java +++ b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketIntegrationTestConfig.java @@ -2,6 +2,7 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.ComponentScan; /** @@ -11,6 +12,7 @@ */ @SpringBootConfiguration @EnableAutoConfiguration +@EnableCaching @ComponentScan(basePackages = "org.opendevstack.apiservice.externalservice.bitbucket") public class BitbucketIntegrationTestConfig { // Configuration class for integration tests diff --git a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java index a0dd930..f848719 100644 --- a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java +++ b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java @@ -1,5 +1,9 @@ package org.opendevstack.apiservice.externalservice.bitbucket.integration; +import org.opendevstack.apiservice.externalservice.bitbucket.client.BitbucketApiClientFactory; +import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration; +import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration.BitbucketInstanceConfig; +import org.opendevstack.apiservice.externalservice.bitbucket.service.impl.BitbucketServiceImpl; import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; import lombok.extern.slf4j.Slf4j; @@ -8,7 +12,9 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.HttpClientErrorException; import java.util.Set; @@ -16,10 +22,8 @@ /** * Integration test for BitbucketService. - * * This test runs against a real Bitbucket instance configured in application-local.yaml. * It requires actual Bitbucket connectivity and valid credentials. - * * To run these tests: * 1. Ensure application-local.yaml has valid Bitbucket configuration * 2. Set environment variable: BITBUCKET_INTEGRATION_TEST_ENABLED=true @@ -28,14 +32,12 @@ * - BITBUCKET_TEST_PROJECT_KEY (e.g., "PROJ") * - BITBUCKET_TEST_REPOSITORY_SLUG (e.g., "my-repo") * - BITBUCKET_TEST_EXISTING_BRANCH (e.g., "develop") - * * Example: * export BITBUCKET_INTEGRATION_TEST_ENABLED=true * export BITBUCKET_TEST_INSTANCE=dev * export BITBUCKET_TEST_PROJECT_KEY=DEVSTACK * export BITBUCKET_TEST_REPOSITORY_SLUG=devstack-api-service * export BITBUCKET_TEST_EXISTING_BRANCH=develop - * * Then run: mvn test -Dtest=BitbucketServiceIntegrationTest */ @SpringBootTest(classes = BitbucketIntegrationTestConfig.class) @@ -47,6 +49,12 @@ class BitbucketServiceIntegrationTest { @Autowired private BitbucketService bitbucketService; + @Autowired + private BitbucketServiceConfiguration bitbucketConfiguration; + + @Autowired + private RestTemplateBuilder restTemplateBuilder; + private String testInstance; private String testProjectKey; private String testRepositorySlug; @@ -284,4 +292,137 @@ void testBranchExists_WithRefPrefix() throws BitbucketException { log.info("Branch lookup works correctly for: {}", defaultBranch); } + + @Test + void testProjectExists_ExistingProject() throws BitbucketException { + // Act - Use the same project key that is already known to exist (used by other tests) + boolean exists = bitbucketService.projectExists(testInstance, testProjectKey); + + // Assert + assertTrue(exists, "Project '" + testProjectKey + "' should exist in instance '" + testInstance + "'"); + log.info("Verified project exists: {}", testProjectKey); + } + + @Test + void testProjectExists_NonExistentProject() throws BitbucketException { + // Arrange + String nonExistentProject = "ZZNONEXIST99"; + + // Act + boolean exists = bitbucketService.projectExists(testInstance, nonExistentProject); + + // Assert + assertFalse(exists, "Non-existent project should return false"); + log.info("Verified project does not exist: {}", nonExistentProject); + } + + @Test + void testProjectExists_InvalidInstance() { + // Arrange + String invalidInstance = "invalid-instance"; + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + bitbucketService.projectExists(invalidInstance, testProjectKey) + ); + + assertTrue( + exception.getMessage().contains("not configured") || + exception.getMessage().contains("No Bitbucket instance"), + "Exception should indicate instance not configured" + ); + log.info("Expected exception for invalid instance: {}", exception.getMessage()); + } + + @Test + void testProjectExists_ConsistentResults() throws BitbucketException { + // Act - Call the method multiple times + boolean exists1 = bitbucketService.projectExists(testInstance, testProjectKey); + boolean exists2 = bitbucketService.projectExists(testInstance, testProjectKey); + + // Assert - Results should be consistent + assertEquals(exists1, exists2, + "projectExists should return consistent results across multiple calls"); + log.info("Verified consistent projectExists result for '{}': {}", testProjectKey, exists1); + } + + /** + * Helper method that creates a BitbucketService backed by a client factory + * configured with the same base URL as the real test instance but with an + * intentionally wrong password, so that every call returns 401 Unauthorized. + */ + private BitbucketService createUnauthorizedBitbucketService() { + // Take the real instance config and clone it with a wrong password + BitbucketInstanceConfig realConfig = bitbucketConfiguration.getInstances().get(testInstance); + assertNotNull(realConfig, "Real instance config for '" + testInstance + "' should exist"); + + BitbucketInstanceConfig badConfig = new BitbucketInstanceConfig(); + badConfig.setBaseUrl(realConfig.getBaseUrl()); + badConfig.setUsername("invalid-user-unauthorized-test"); + badConfig.setPassword("wrong-password-12345"); + badConfig.setBearerToken(null); // force basic auth with bad credentials + badConfig.setConnectionTimeout(realConfig.getConnectionTimeout()); + badConfig.setReadTimeout(realConfig.getReadTimeout()); + badConfig.setTrustAllCertificates(realConfig.isTrustAllCertificates()); + + BitbucketServiceConfiguration badConfiguration = new BitbucketServiceConfiguration(); + badConfiguration.setInstances(java.util.Map.of("unauthorized", badConfig)); + + BitbucketApiClientFactory badFactory = new BitbucketApiClientFactory(badConfiguration, restTemplateBuilder); + return new BitbucketServiceImpl(badFactory); + } + + @Test + void testGetDefaultBranch_Unauthorized() { + // Arrange – service with wrong password + BitbucketService unauthorizedService = createUnauthorizedBitbucketService(); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + unauthorizedService.getDefaultBranch("unauthorized", testProjectKey, testRepositorySlug) + ); + + assertNotNull(exception.getCause(), "Exception should have a cause"); + assertInstanceOf(HttpClientErrorException.Unauthorized.class, exception.getCause(), + "Cause should be 401 Unauthorized, but was: " + exception.getCause().getClass().getSimpleName()); + assertTrue(exception.getMessage().contains("Authentication failed (401 Unauthorized)"), + "Exception message should indicate authentication failure, but was: " + exception.getMessage()); + log.info("getDefaultBranch correctly failed with Unauthorized: {}", exception.getMessage()); + } + + @Test + void testBranchExists_Unauthorized() { + // Arrange – service with wrong password + BitbucketService unauthorizedService = createUnauthorizedBitbucketService(); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + unauthorizedService.branchExists("unauthorized", testProjectKey, testRepositorySlug, "main") + ); + + assertNotNull(exception.getCause(), "Exception should have a cause"); + assertInstanceOf(HttpClientErrorException.Unauthorized.class, exception.getCause(), + "Cause should be 401 Unauthorized, but was: " + exception.getCause().getClass().getSimpleName()); + assertTrue(exception.getMessage().contains("Authentication failed (401 Unauthorized)"), + "Exception message should indicate authentication failure, but was: " + exception.getMessage()); + log.info("branchExists correctly failed with Unauthorized: {}", exception.getMessage()); + } + + @Test + void testProjectExists_Unauthorized() { + // Arrange – service with wrong password + BitbucketService unauthorizedService = createUnauthorizedBitbucketService(); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + unauthorizedService.projectExists("unauthorized", testProjectKey) + ); + + assertNotNull(exception.getCause(), "Exception should have a cause"); + assertInstanceOf(HttpClientErrorException.Unauthorized.class, exception.getCause(), + "Cause should be 401 Unauthorized, but was: " + exception.getCause().getClass().getSimpleName()); + assertTrue(exception.getMessage().contains("Authentication failed (401 Unauthorized)"), + "Exception message should indicate authentication failure, but was: " + exception.getMessage()); + log.info("projectExists correctly failed with Unauthorized: {}", exception.getMessage()); + } } diff --git a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketServiceTest.java b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketServiceTest.java index cad74ad..fe569cd 100644 --- a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketServiceTest.java +++ b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/service/BitbucketServiceTest.java @@ -6,6 +6,7 @@ import org.opendevstack.apiservice.externalservice.bitbucket.client.model.GetBranches200Response; import org.opendevstack.apiservice.externalservice.bitbucket.client.model.RestBranch; import org.opendevstack.apiservice.externalservice.bitbucket.client.model.RestMinimalRef; +import org.opendevstack.apiservice.externalservice.bitbucket.client.model.RestProject; import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; import org.opendevstack.apiservice.externalservice.bitbucket.service.impl.BitbucketServiceImpl; import org.junit.jupiter.api.BeforeEach; @@ -15,6 +16,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.ResponseEntity; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; import java.util.Collections; @@ -280,4 +282,188 @@ void testBranchExists_False() throws Exception { verify(clientFactory).getClient(instanceName); verify(bitbucketApiClient).getApiClient(); } + + @Test + void testProjectExists_True() throws Exception { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + + when(clientFactory.getClient(instanceName)).thenReturn(bitbucketApiClient); + when(bitbucketApiClient.getApiClient()).thenReturn(apiClient); + + // Mock invokeAPI to return a successful response (project found) + RestProject restProject = new RestProject(); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(ResponseEntity.ok(restProject)); + + // Act + boolean result = bitbucketService.projectExists(instanceName, projectKey); + + // Assert + assertTrue(result); + verify(clientFactory).getClient(instanceName); + verify(bitbucketApiClient).getApiClient(); + } + + @Test + void testProjectExists_False_NotFound() throws Exception { + // Arrange + String instanceName = "dev"; + String projectKey = "NONEXISTENT"; + + when(clientFactory.getClient(instanceName)).thenReturn(bitbucketApiClient); + when(bitbucketApiClient.getApiClient()).thenReturn(apiClient); + + // Mock invokeAPI to throw HttpClientErrorException.NotFound (404) + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(HttpClientErrorException.NotFound.create( + org.springframework.http.HttpStatus.NOT_FOUND, "Not Found", + org.springframework.http.HttpHeaders.EMPTY, new byte[0], null)); + + // Act + boolean result = bitbucketService.projectExists(instanceName, projectKey); + + // Assert + assertFalse(result); + verify(clientFactory).getClient(instanceName); + verify(bitbucketApiClient).getApiClient(); + } + + @Test + void testProjectExists_InstanceNotConfigured() throws BitbucketException { + // Arrange + String instanceName = "nonexistent"; + String projectKey = "PROJ"; + + when(clientFactory.getClient(instanceName)) + .thenThrow(new BitbucketException("Instance not configured")); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + bitbucketService.projectExists(instanceName, projectKey) + ); + + assertTrue(exception.getMessage().contains("Instance not configured")); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testProjectExists_ThrowsExceptionOnNetworkError() throws BitbucketException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + + when(clientFactory.getClient(instanceName)).thenReturn(bitbucketApiClient); + when(bitbucketApiClient.getApiClient()).thenReturn(apiClient); + + // Mock invokeAPI to throw RestClientException (network / infrastructure error) + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + bitbucketService.projectExists(instanceName, projectKey) + ); + + assertTrue(exception.getMessage().contains("Failed to check if project")); + assertTrue(exception.getMessage().contains(projectKey)); + assertNotNull(exception.getCause()); + verify(clientFactory).getClient(instanceName); + verify(bitbucketApiClient).getApiClient(); + } + + @Test + void testGetDefaultBranch_Unauthorized() throws BitbucketException { + // Arrange – simulate a Bitbucket client configured with wrong password + String instanceName = "dev"; + String projectKey = "PROJ"; + String repositorySlug = "my-repo"; + + when(clientFactory.getClient(instanceName)).thenReturn(bitbucketApiClient); + when(bitbucketApiClient.getApiClient()).thenReturn(apiClient); + + // Mock invokeAPI to throw HttpClientErrorException.Unauthorized (401) – wrong password + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(HttpClientErrorException.Unauthorized.create( + org.springframework.http.HttpStatus.UNAUTHORIZED, "Unauthorized", + org.springframework.http.HttpHeaders.EMPTY, new byte[0], null)); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + bitbucketService.getDefaultBranch(instanceName, projectKey, repositorySlug) + ); + + assertNotNull(exception.getCause()); + assertInstanceOf(HttpClientErrorException.Unauthorized.class, exception.getCause(), + "Cause should be HttpClientErrorException.Unauthorized"); + assertTrue(exception.getMessage().contains("Authentication failed (401 Unauthorized)"), + "Exception message should indicate authentication failure, but was: " + exception.getMessage()); + verify(clientFactory).getClient(instanceName); + verify(bitbucketApiClient).getApiClient(); + } + + @Test + void testBranchExists_Unauthorized() throws BitbucketException { + // Arrange – simulate a Bitbucket client configured with wrong password + String instanceName = "dev"; + String projectKey = "PROJ"; + String repositorySlug = "my-repo"; + String branchName = "feature/test"; + + when(clientFactory.getClient(instanceName)).thenReturn(bitbucketApiClient); + when(bitbucketApiClient.getApiClient()).thenReturn(apiClient); + + // Mock the parameterToMultiValueMap method to return empty map instead of null + when(apiClient.parameterToMultiValueMap(any(), anyString(), any())) + .thenReturn(new LinkedMultiValueMap<>()); + + // Mock invokeAPI to throw HttpClientErrorException.Unauthorized (401) – wrong password + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(HttpClientErrorException.Unauthorized.create( + org.springframework.http.HttpStatus.UNAUTHORIZED, "Unauthorized", + org.springframework.http.HttpHeaders.EMPTY, new byte[0], null)); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + bitbucketService.branchExists(instanceName, projectKey, repositorySlug, branchName) + ); + + assertNotNull(exception.getCause()); + assertInstanceOf(HttpClientErrorException.Unauthorized.class, exception.getCause(), + "Cause should be HttpClientErrorException.Unauthorized"); + assertTrue(exception.getMessage().contains("Authentication failed (401 Unauthorized)"), + "Exception message should indicate authentication failure, but was: " + exception.getMessage()); + verify(clientFactory).getClient(instanceName); + verify(bitbucketApiClient).getApiClient(); + } + + @Test + void testProjectExists_Unauthorized() throws BitbucketException { + // Arrange – simulate a Bitbucket client configured with wrong password + String instanceName = "dev"; + String projectKey = "PROJ"; + + when(clientFactory.getClient(instanceName)).thenReturn(bitbucketApiClient); + when(bitbucketApiClient.getApiClient()).thenReturn(apiClient); + + // Mock invokeAPI to throw HttpClientErrorException.Unauthorized (401) – wrong password + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(HttpClientErrorException.Unauthorized.create( + org.springframework.http.HttpStatus.UNAUTHORIZED, "Unauthorized", + org.springframework.http.HttpHeaders.EMPTY, new byte[0], null)); + + // Act & Assert + BitbucketException exception = assertThrows(BitbucketException.class, () -> + bitbucketService.projectExists(instanceName, projectKey) + ); + + assertNotNull(exception.getCause()); + assertInstanceOf(HttpClientErrorException.Unauthorized.class, exception.getCause(), + "Cause should be HttpClientErrorException.Unauthorized"); + assertTrue(exception.getMessage().contains("Authentication failed (401 Unauthorized)"), + "Exception message should indicate authentication failure, but was: " + exception.getMessage()); + verify(clientFactory).getClient(instanceName); + verify(bitbucketApiClient).getApiClient(); + } }